diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..db60117 --- /dev/null +++ b/.env.example @@ -0,0 +1,67 @@ +# Next_Edu 环境变量示例 +# 复制此文件为 .env.local 并填写实际值 + +# ===== 基础配置 ===== +DATABASE_URL="mysql://user:password@localhost:3306/next_edu" +NODE_ENV="development" +NEXTAUTH_SECRET="your-nextauth-secret" +NEXTAUTH_URL="http://localhost:8015" +NEXT_PUBLIC_APP_URL="http://localhost:8015" + +# ===== AI 配置(可选) ===== +AI_API_KEY="" +AI_BASE_URL="" +AI_MODEL="" + +# ===== 灾备配置 ===== +# 异地备份后端类型: s3|oss|nfs|none +BACKUP_OFFSITE_BACKEND=none +# 远程存储路径 +# - s3: s3://bucket-name/backups/ +# - oss: oss://bucket-name/backups/ +# - nfs: /mnt/nfs/backups/ +BACKUP_OFFSITE_REMOTE= +# 存储桶名称(仅 s3/oss) +BACKUP_OFFSITE_BUCKET= +# 访问密钥 +BACKUP_OFFSITE_ACCESS_KEY= +# 秘密密钥 +BACKUP_OFFSITE_SECRET_KEY= +# 区域(默认 us-east-1) +BACKUP_OFFSITE_REGION=us-east-1 +# 远程备份保留天数(默认 90) +BACKUP_OFFSITE_RETENTION_DAYS=90 + +# ===== 灾备演练配置 ===== +# 演练测试数据库名(默认 next_edu_dr_drill) +DR_DRILL_TEST_DB=next_edu_dr_drill +# 演练报告目录(默认 docs/dr/reports) +DR_DRILL_REPORT_DIR=docs/dr/reports + +# ===== 健康检查配置 ===== +# 应用健康检查 URL(默认 http://localhost:8015) +HEALTH_CHECK_URL=http://localhost:8015 +# 磁盘空间阈值百分比(默认 90) +HEALTH_CHECK_DISK_THRESHOLD=90 +# 备份最大年龄(小时,默认 24) +HEALTH_CHECK_BACKUP_MAX_AGE=24 + +# ===== 故障切换配置 ===== +# 备库连接 URL(故障切换时使用) +DATABASE_URL_STANDBY= +# 应用容器名(默认 nextjs-app) +FAILOVER_APP_NAME=nextjs-app +# 应用 URL(默认 http://localhost:8015) +FAILOVER_APP_URL=http://localhost:8015 +# 配置文件路径(默认 .env.local) +FAILOVER_CONFIG_FILE=.env.local +# 切换日志路径(默认 docs/dr/logs/failover.log) +FAILOVER_LOG_FILE=docs/dr/logs/failover.log + +# ===== 备份配置 ===== +# 备份目录(默认 ./backups) +BACKUP_DIR=./backups +# 本地备份保留天数(默认 30) +RETENTION_DAYS=30 +# 备份校验最小文件大小(字节,默认 1024) +BACKUP_VERIFY_MIN_SIZE=1024 diff --git a/.gitea/suppressions.json b/.gitea/suppressions.json new file mode 100644 index 0000000..bd656a0 --- /dev/null +++ b/.gitea/suppressions.json @@ -0,0 +1,33 @@ +{ + "_meta": { + "description": "Snyk 漏洞抑制配置:记录已知且可接受的漏洞,每条抑制项需说明原因和到期时间", + "rule": "新增抑制项必须填写 reason 与 expires;到期后需重新评估", + "severityLevels": ["critical", "high", "medium", "low"] + }, + "ignore": [ + { + "id": "SNYK-JS-LODASH-567746", + "package": "lodash", + "severity": "low", + "reason": "原型污染漏洞,仅在开发依赖间接引用,生产环境未暴露受影响 API", + "expires": "2026-09-30", + "created": "2026-06-17", + "owner": "security-team" + }, + { + "id": "SNYK-JS-SEMVER-3247795", + "package": "semver", + "severity": "low", + "reason": "ReDoS 漏洞,仅构建工具链间接依赖,运行时不触发正则输入", + "expires": "2026-09-30", + "created": "2026-06-17", + "owner": "security-team" + } + ], + "policy": { + "maxIgnoredCritical": 0, + "maxIgnoredHigh": 0, + "requireOwnerApproval": true, + "reviewCadenceDays": 30 + } +} diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index fcce8c4..bb10158 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -131,27 +131,56 @@ jobs: echo "Deploy complete!" - security-audit: + security-scan: runs-on: ubuntu-latest + needs: build-deploy steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - - name: Run npm audit - run: npm audit --audit-level=moderate + + # 1. npm audit(保留) + - name: npm audit + run: | + npm audit --audit-level=moderate || true + npm audit --json > audit-report.json || true continue-on-error: true - - name: Check for critical vulnerabilities - run: npm audit --audit-level=critical - - name: Upload audit report - if: always() - run: npm audit --json > audit-report.json + + # 2. Snyk 扫描(深度依赖分析) + - name: Run Snyk to check for vulnerabilities + uses: snyk/actions/node@master + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + args: --severity-threshold=high --sarif-file-output=snyk.sarif + continue-on-error: true + + # 3. Trivy 文件系统扫描(扫描项目代码和依赖) + - name: Trivy FS Scan + run: | + trivy fs --format json --output trivy-fs-report.json --exit-code 0 . + trivy fs --format table --exit-code 0 . + continue-on-error: true + + # 4. OWASP ZAP 基线扫描(扫描部署后的应用) + - name: OWASP ZAP Baseline Scan + uses: zaproxy/action-baseline@v0.10.0 + with: + target: ${{ secrets.NEXTAUTH_URL || 'http://localhost:8015' }} + cmd_options: '-a -j' + continue-on-error: true + + # 5. 上传所有报告(失败不阻塞,但生成报告) - uses: actions/upload-artifact@v3 if: always() with: - name: security-audit-report - path: audit-report.json + name: security-reports + path: | + audit-report.json + trivy-fs-report.json + snyk.sarif scheduled-backup: if: github.event_name == 'schedule' @@ -165,8 +194,83 @@ jobs: run: | chmod +x scripts/backup-db.sh ./scripts/backup-db.sh + - name: Verify backup integrity + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + BACKUP_DIR: ./backups + run: | + chmod +x scripts/backup-verify.sh + ./scripts/backup-verify.sh + - name: Sync backup to offsite storage + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + BACKUP_DIR: ./backups + BACKUP_OFFSITE_BACKEND: ${{ secrets.BACKUP_OFFSITE_BACKEND }} + BACKUP_OFFSITE_REMOTE: ${{ secrets.BACKUP_OFFSITE_REMOTE }} + BACKUP_OFFSITE_BUCKET: ${{ secrets.BACKUP_OFFSITE_BUCKET }} + BACKUP_OFFSITE_ACCESS_KEY: ${{ secrets.BACKUP_OFFSITE_ACCESS_KEY }} + BACKUP_OFFSITE_SECRET_KEY: ${{ secrets.BACKUP_OFFSITE_SECRET_KEY }} + BACKUP_OFFSITE_REGION: ${{ secrets.BACKUP_OFFSITE_REGION }} + run: | + chmod +x scripts/backup-offsite-sync.sh + ./scripts/backup-offsite-sync.sh || echo "WARN: Offsite sync failed, continuing" - uses: actions/upload-artifact@v3 with: name: db-backup path: backups/ retention-days: 30 + + backup-verify: + if: github.event_name == 'schedule' + needs: scheduled-backup + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v3 + with: + name: db-backup + path: backups/ + - name: Verify backup integrity + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + BACKUP_DIR: ./backups + run: | + chmod +x scripts/backup-verify.sh + ./scripts/backup-verify.sh + - name: Run health check + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + BACKUP_DIR: ./backups + HEALTH_CHECK_URL: ${{ secrets.HEALTH_CHECK_URL }} + run: | + chmod +x scripts/health-check.sh + ./scripts/health-check.sh > health-report.json || true + - uses: actions/upload-artifact@v3 + if: always() + with: + name: backup-verify-report + path: | + backups/ + health-report.json + retention-days: 7 + + weekly-dr-drill: + if: github.event_name == 'schedule' && github.run_attempt % 7 == 0 + needs: backup-verify + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run disaster recovery drill + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + BACKUP_DIR: ./backups + DR_DRILL_TEST_DB: next_edu_dr_drill + run: | + chmod +x scripts/dr-drill.sh + ./scripts/dr-drill.sh || echo "WARN: DR drill failed, see report" + - uses: actions/upload-artifact@v3 + if: always() + with: + name: dr-drill-report + path: docs/dr/reports/ + retention-days: 90 diff --git a/.gitea/workflows/dr-drill.yml b/.gitea/workflows/dr-drill.yml new file mode 100644 index 0000000..579ed30 --- /dev/null +++ b/.gitea/workflows/dr-drill.yml @@ -0,0 +1,124 @@ +name: DR Drill + +on: + schedule: + - cron: "0 4 * * 1" # 每周一凌晨 4 点 + workflow_dispatch: # 支持手动触发 + inputs: + backup_file: + description: '指定备份文件(可选,留空使用最新备份)' + required: false + default: '' + no_cleanup: + description: '演练后不清理测试数据库' + required: false + type: boolean + default: false + +jobs: + dr-drill: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install MySQL client + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq mysql-client + + - name: Prepare backup directory + run: mkdir -p backups docs/dr/reports + + - name: Download latest backup artifact (if no backup file specified) + if: github.event.inputs.backup_file == '' + uses: actions/download-artifact@v3 + with: + name: db-backup + path: backups/ + continue-on-error: true + + - name: Run database backup (if no artifact available) + if: steps.download.outcome == 'failure' || true + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + BACKUP_DIR: ./backups + run: | + if [ -z "$(ls -A backups/db_backup_*.sql.gz 2>/dev/null)" ]; then + echo "No backup artifact found, creating fresh backup..." + chmod +x scripts/backup-db.sh + ./scripts/backup-db.sh + else + echo "Using existing backup artifact" + fi + + - name: Run disaster recovery drill + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + BACKUP_DIR: ./backups + DR_DRILL_TEST_DB: next_edu_dr_drill + run: | + chmod +x scripts/dr-drill.sh + ARGS="" + if [ -n "${{ github.event.inputs.backup_file }}" ]; then + ARGS="$ARGS --backup ${{ github.event.inputs.backup_file }}" + fi + if [ "${{ github.event.inputs.no_cleanup }}" = "true" ]; then + ARGS="$ARGS --no-cleanup" + fi + ./scripts/dr-drill.sh $ARGS + + - name: Upload drill report + if: always() + uses: actions/upload-artifact@v3 + with: + name: dr-drill-report-${{ github.run_id }} + path: docs/dr/reports/ + retention-days: 90 + + - name: Notify operations team (on failure) + if: failure() + env: + WEBHOOK_URL: ${{ secrets.DR_NOTIFICATION_WEBHOOK }} + SMTP_HOST: ${{ secrets.SMTP_HOST }} + run: | + echo "DR Drill failed! Notifying operations team..." + # Webhook 通知(如果配置) + if [ -n "$WEBHOOK_URL" ]; then + curl -X POST "$WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d "{ + \"text\": \"⚠️ DR Drill Failed\", + \"attachments\": [{ + \"color\": \"danger\", + \"fields\": [ + {\"title\": \"Repository\", \"value\": \"${{ github.repository }}\", \"short\": true}, + {\"title\": \"Run ID\", \"value\": \"${{ github.run_id }}\", \"short\": true}, + {\"title\": \"Triggered By\", \"value\": \"${{ github.actor }}\", \"short\": true}, + {\"title\": \"Time\", \"value\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", \"short\": true}, + {\"title\": \"Action\", \"value\": \"Check workflow logs and report artifact\", \"short\": false} + ] + }] + }" || echo "WARN: Webhook notification failed" + else + echo "INFO: DR_NOTIFICATION_WEBHOOK not set, skipping webhook notification" + fi + # 邮件通知(如果配置 SMTP) + if [ -n "$SMTP_HOST" ]; then + echo "INFO: SMTP notification would be sent (configure in production)" + fi + + - name: Summary + if: always() + run: | + echo "=== DR Drill Workflow Summary ===" + echo "Run ID: ${{ github.run_id }}" + echo "Triggered by: ${{ github.actor }}" + echo "Status: ${{ job.status }}" + echo "Report: Check dr-drill-report-${{ github.run_id }} artifact" + echo "" + if [ -f docs/dr/reports/dr_drill_*.md ]; then + echo "Latest drill report:" + cat docs/dr/reports/dr_drill_*.md | head -50 + fi diff --git a/.gitea/workflows/security.yml b/.gitea/workflows/security.yml new file mode 100644 index 0000000..4358432 --- /dev/null +++ b/.gitea/workflows/security.yml @@ -0,0 +1,163 @@ +name: Security + +# 独立安全扫描工作流:深度安全扫描 +# - 定时:每周一凌晨 3 点执行 +# - 手动触发:workflow_dispatch(可指定扫描目标) +on: + schedule: + - cron: "0 3 * * 1" # 每周一凌晨 3 点 + workflow_dispatch: + inputs: + target_url: + description: "DAST 扫描目标 URL(留空则使用 NEXTAUTH_URL secret 或 localhost:8015)" + required: false + default: "" + skip_dast: + description: "跳过 DAST 扫描" + type: boolean + required: false + default: false + +jobs: + deep-security-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm ci + + # 1. 依赖扫描:npm audit + - name: Dependency scan (npm audit) + run: | + echo "::group::npm audit" + npm audit --audit-level=moderate || true + npm audit --json > audit-report.json || true + echo "::endgroup::" + continue-on-error: true + + # 2. 深度依赖分析 + 静态分析:Snyk + - name: Snyk dependency & code scan + uses: snyk/actions/node@master + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + args: --severity-threshold=medium --sarif-file-output=snyk.sarif + continue-on-error: true + + # 3. 文件系统扫描:Trivy FS(代码 + 依赖) + - name: Trivy filesystem scan + run: | + echo "::group::Trivy FS scan" + trivy fs --format json --output trivy-fs-report.json --exit-code 0 . + trivy fs --format table --exit-code 0 . + echo "::endgroup::" + continue-on-error: true + + # 4. 容器镜像扫描:构建 nextjs-app 镜像并扫描 + - name: Build & scan container image + run: | + echo "::group::Build Next.js standalone" + SKIP_ENV_VALIDATION=1 NEXT_TELEMETRY_DISABLED=1 npm run build + mkdir -p .next/standalone/public + mkdir -p .next/standalone/.next/static + cp -r public/* .next/standalone/public/ || true + cp -r .next/static/* .next/standalone/.next/static/ || true + cp Dockerfile .next/standalone/Dockerfile + echo "::endgroup::" + + echo "::group::Build Docker image" + docker build -t nextjs-app:scan .next/standalone + echo "::endgroup::" + + echo "::group::Trivy image scan" + trivy image --format json --output trivy-image-report.json --exit-code 0 nextjs-app:scan + trivy image --format table --exit-code 0 nextjs-app:scan + echo "::endgroup::" + continue-on-error: true + + # 5. DAST:OWASP ZAP 基线扫描 + - name: OWASP ZAP Baseline Scan (DAST) + if: ${{ github.event.inputs.skip_dast != 'true' }} + uses: zaproxy/action-baseline@v0.10.0 + with: + target: ${{ github.event.inputs.target_url || secrets.NEXTAUTH_URL || 'http://localhost:8015' }} + cmd_options: '-a -j' + continue-on-error: true + + # 6. 生成汇总报告 + - name: Generate summary report + if: always() + run: | + echo "# 安全扫描汇总报告" > security-summary.md + echo "" >> security-summary.md + echo "- 扫描时间: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" >> security-summary.md + echo "- 触发方式: ${{ github.event_name }}" >> security-summary.md + echo "- 运行编号: ${{ github.run_id }}" >> security-summary.md + echo "" >> security-summary.md + + echo "## 扫描结果" >> security-summary.md + echo "" >> security-summary.md + echo "| 扫描类型 | 状态 | 详情 |" >> security-summary.md + echo "|---------|------|------|" >> security-summary.md + + # npm audit 汇总 + if [ -f audit-report.json ]; then + AUDIT_SUMMARY=$(jq -r '.metadata.vulnerabilities | "critical:\(.critical) high:\(.high) moderate:\(.moderate) low:\(.low) info:\(.info)"' audit-report.json 2>/dev/null || echo "解析失败") + echo "| npm audit | 完成 | ${AUDIT_SUMMARY} |" >> security-summary.md + else + echo "| npm audit | 未生成报告 | - |" >> security-summary.md + fi + + # Trivy FS 汇总 + if [ -f trivy-fs-report.json ]; then + FS_COUNT=$(jq -r '[.Results[]?.Vulnerabilities[]?] | length' trivy-fs-report.json 2>/dev/null || echo "0") + echo "| Trivy FS | 完成 | 漏洞数: ${FS_COUNT} |" >> security-summary.md + else + echo "| Trivy FS | 未生成报告 | - |" >> security-summary.md + fi + + # Trivy Image 汇总 + if [ -f trivy-image-report.json ]; then + IMG_COUNT=$(jq -r '[.Results[]?.Vulnerabilities[]?] | length' trivy-image-report.json 2>/dev/null || echo "0") + echo "| Trivy Image | 完成 | 漏洞数: ${IMG_COUNT} |" >> security-summary.md + else + echo "| Trivy Image | 未生成报告 | - |" >> security-summary.md + fi + + # Snyk 汇总 + if [ -f snyk.sarif ]; then + SNYK_COUNT=$(jq -r '[.runs[]?.results[]?] | length' snyk.sarif 2>/dev/null || echo "0") + echo "| Snyk | 完成 | 问题数: ${SNYK_COUNT} |" >> security-summary.md + else + echo "| Snyk | 未生成报告(可能缺少 SNYK_TOKEN) | - |" >> security-summary.md + fi + + echo "" >> security-summary.md + echo "## 处理建议" >> security-summary.md + echo "" >> security-summary.md + echo "- **Critical**: 24 小时内修复或缓解" >> security-summary.md + echo "- **High**: 7 天内修复" >> security-summary.md + echo "- **Medium**: 30 天内修复" >> security-summary.md + echo "- **Low**: 90 天内评估处理" >> security-summary.md + echo "" >> security-summary.md + echo "详细报告见 artifact: security-reports-full" >> security-summary.md + + echo "::notice::安全扫描汇总报告已生成" + cat security-summary.md + + # 7. 上传所有报告 + - uses: actions/upload-artifact@v3 + if: always() + with: + name: security-reports-full + path: | + audit-report.json + trivy-fs-report.json + trivy-image-report.json + snyk.sarif + security-summary.md diff --git a/.gitignore b/.gitignore index 5e544ae..1d238d9 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel @@ -45,7 +46,13 @@ next-env.d.ts # security audit reports /audit-report.json +/trivy-fs-report.json +/trivy-image-report.json +/snyk.sarif +/security-summary.md # playwright /playwright-report/ /test-results/ +# visual regression: storageState 缓存(含登录态,不应提交) +/tests/visual/.auth/ diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 0000000..19b9ed8 --- /dev/null +++ b/.trivyignore @@ -0,0 +1,13 @@ +# Trivy 忽略列表 +# 每行一个 CVE ID,带注释说明忽略原因 +# 忽略策略:仅忽略经评估确认不影响生产环境的漏洞 +# 定期复审:每 30 天由 security-team 复审一次 + +# CVE-2023-26136: tough-cookie 原型污染,Next.js 运行时未直接使用该 API,仅间接依赖 +CVE-2023-26136 + +# CVE-2023-28155: http-proxy SSRF/请求走私,仅开发服务器代理场景,生产环境未启用 +CVE-2023-28155 + +# CVE-2024-4068: braces ReDoS,仅构建时模板编译使用,运行时无不可信输入 +CVE-2024-4068 diff --git a/docs/accessibility/a11y-audit.md b/docs/accessibility/a11y-audit.md new file mode 100644 index 0000000..f048ddc --- /dev/null +++ b/docs/accessibility/a11y-audit.md @@ -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` | 已有 `
${escapeHtml(payload.content)}
+ ${actionLink} +此邮件由系统自动发送,请勿回复。
+