393 lines
31 KiB
Markdown
393 lines
31 KiB
Markdown
# 公告(announcements)模块审计报告
|
||
|
||
> 审查日期:2026-06-25
|
||
> 审查范围:`src/modules/announcements/**`、`src/app/(dashboard)/announcements/**`、`src/app/(dashboard)/admin/announcements/**`、`src/shared/i18n/messages/{zh-CN,en}/announcements.json`
|
||
> 架构图参考:`docs/architecture/004_architecture_impact_map.md` §2.16、`docs/architecture/005_architecture_data.json#announcements`
|
||
> 关联报告:`announcements-messages-audit-report.md`(合并版,2026-06-22,已不再维护,本文为公告模块的独立深度审计)
|
||
|
||
---
|
||
|
||
## 一、现有实现概要
|
||
|
||
### 1.1 文件分布
|
||
|
||
| 层 | 路径 | 文件 | 行数 | 说明 |
|
||
|----|------|------|------|------|
|
||
| 路由 · 用户端 | `src/app/(dashboard)/announcements/page.tsx` | 1 | 36 | 列表页(所有非管理角色共用) |
|
||
| 路由 · 用户端 | `src/app/(dashboard)/announcements/[id]/page.tsx` | 1 | 41 | 详情页(只读) |
|
||
| 路由 · 用户端 | `src/app/(dashboard)/announcements/{loading,error,[id]/error}.tsx` | 3 | 60 | 骨架屏 + 错误边界 |
|
||
| 路由 · 管理端 | `src/app/(dashboard)/admin/announcements/page.tsx` | 1 | 45 | 管理列表页 |
|
||
| 路由 · 管理端 | `src/app/(dashboard)/admin/announcements/[id]/page.tsx` | 1 | 47 | 编辑页(直接渲染表单,无详情视图) |
|
||
| 路由 · 管理端 | `src/app/(dashboard)/admin/announcements/{loading,error,[id]/error}.tsx` | 3 | 64 | 骨架屏 + 错误边界 |
|
||
| 模块 | `src/modules/announcements/actions.ts` | 1 | 403 | 9 个 Server Action + 通知编排 + 埋点 |
|
||
| 模块 | `src/modules/announcements/data-access.ts` | 1 | 413 | CRUD + 发布/归档 + 置顶/已读 + 3 个页面编排函数 |
|
||
| 模块 | `src/modules/announcements/types.ts` | 1 | 75 | 类型定义 |
|
||
| 模块 | `src/modules/announcements/schema.ts` | 1 | 95 | Zod 校验 + `refineAudience` 条件校验 |
|
||
| 模块 · 组件 | `src/modules/announcements/components/announcement-list.tsx` | 1 | 126 | 列表(纯服务端过滤) |
|
||
| 模块 · 组件 | `src/modules/announcements/components/announcement-card.tsx` | 1 | 125 | 卡片 + 置顶切换 |
|
||
| 模块 · 组件 | `src/modules/announcements/components/announcement-detail.tsx` | 1 | 267 | 详情 + 管理操作 + 自动已读 |
|
||
| 模块 · 组件 | `src/modules/announcements/components/announcement-form.tsx` | 1 | 230 | 创建/编辑表单 |
|
||
| 模块 · 组件 | `src/modules/announcements/components/admin-announcements-view.tsx` | 1 | 67 | 管理端视图(列表 + 创建 Dialog) |
|
||
| i18n | `src/shared/i18n/messages/{zh-CN,en}/announcements.json` | 2 | 103/103 | 11 命名空间翻译字典 |
|
||
| 测试 | — | 0 | 0 | **零测试文件** |
|
||
|
||
文件大小均在规范内(组件 ≤500 行,actions/data-access ≤800 行)。
|
||
|
||
### 1.2 数据流
|
||
|
||
```
|
||
[Route] /announcements/page.tsx
|
||
└─▶ announcements/data-access.getUserAnnouncementsPageData(userId, dataScope)
|
||
├─▶ resolveAudience(userId, dataScope) // 内部函数
|
||
│ └─▶ classes/data-access.{getClassGradeId | getStudentActiveClassId | getStudentActiveGradeId}
|
||
└─▶ getAnnouncements({ status: "published", audience })
|
||
|
||
[Route] /announcements/[id]/page.tsx ⚠️ 未做受众/状态过滤
|
||
├─▶ announcements/data-access.getAnnouncementById(id)
|
||
└─▶ announcements/data-access.isAnnouncementReadByUser(id, userId)
|
||
|
||
[Route] /admin/announcements/page.tsx
|
||
└─▶ announcements/data-access.getAdminAnnouncementsPageData(status)
|
||
├─▶ getAnnouncements({ status })
|
||
├─▶ school/data-access.getGrades()
|
||
└─▶ classes/data-access.getAdminClasses()
|
||
|
||
[Route] /admin/announcements/[id]/page.tsx
|
||
└─▶ announcements/data-access.getEditAnnouncementPageData(id)
|
||
├─▶ getAnnouncementById(id)
|
||
└─▶ school/data-access.getGrades()
|
||
|
||
[Action] createAnnouncementAction / updateAnnouncementAction / publishAnnouncementAction
|
||
└─▶ notifyAnnouncementPublished(announcement)
|
||
├─▶ resolveTargetUserIds(announcement) // ⚠️ 纯业务逻辑在 actions.ts
|
||
│ ├─▶ users/data-access.{getAllUserIds | getUserIdsByGradeId}
|
||
│ └─▶ classes/data-access.{getStudentIdsByClassId | getTeacherIdsByClassIds}
|
||
└─▶ notifications.sendBatchNotifications(payloads)
|
||
```
|
||
|
||
### 1.3 架构图记录情况
|
||
|
||
`004_architecture_impact_map.md` §2.16 对 announcements 模块的记录较为完整:
|
||
- ✅ 导出函数(9 个 Action + 14 个 data-access 函数)记录准确
|
||
- ✅ 依赖关系(`shared/*`、`@/auth`、`school`、`classes`、`users`、`notifications`)记录准确
|
||
- ✅ 已修复问题清单(P1-2/P1-5/P1-6/V2-P0-2/V2-P1-1/V2-P1-4/V2-P2-13d/V3-P0-2)记录详实
|
||
- ✅ 文件清单与组件清单行数准确
|
||
|
||
**但架构图存在以下遗漏/不一致**(详见第五章):
|
||
1. 未记录 `AnnouncementDetail` 组件中 `canManage=true` 分支为**死代码**(无任何页面使用)
|
||
2. 未记录 `getAnnouncementReadStatusAction` 为**死代码**(无任何调用方)
|
||
3. 未记录 `/announcements/[id]` 路由层存在的**安全越权风险**(无受众过滤)
|
||
4. 未记录 `resolveAudience` 内部函数的**多孩子/多年级数据截断 Bug**
|
||
5. 未记录 actions 返回的英文字符串未走 i18n 的问题
|
||
|
||
---
|
||
|
||
## 二、现存问题与原因分析
|
||
|
||
### 2.1 【P0 · 安全越权】用户端详情页无受众/状态过滤
|
||
|
||
- **位置**:[src/app/(dashboard)/announcements/[id]/page.tsx:26-27](file:///e:/Desktop/CICD/src/app/(dashboard)/announcements/[id]/page.tsx)
|
||
- **问题**:详情页直接调用 `getAnnouncementById(id)`,未传入 `audience` 也未校验 `status === "published"`。
|
||
- **后果**:任意持有 `ANNOUNCEMENT_READ` 权限的登录用户,只要知道/猜到公告 ID(cuid2),即可读取:
|
||
- 草稿(`status="draft"`)公告——提前泄露未发布内容
|
||
- 已归档(`status="archived"`)公告——绕过归档语义
|
||
- 其他年级/班级的定向公告——跨班级信息泄露(如某班处分通知被外班学生读到)
|
||
- **违反规则**:项目规则"安全性:所有敏感数据查询必须在 data-access 层结合当前用户权限过滤" + "Parent routes must include permission checks with both `parentId` and `studentId` to prevent information leakage"。
|
||
- **根因**:`getAnnouncementById` 设计为通用读取函数,未提供"按受众过滤"重载;路由层也未在读取后做二次校验。
|
||
|
||
### 2.2 【P0 · 数据截断】`resolveAudience` 仅取首个 gradeId / classId / childId
|
||
|
||
- **位置**:[src/modules/announcements/data-access.ts:351-395](file:///e:/Desktop/CICD/src/modules/announcements/data-access.ts)
|
||
- **问题**:
|
||
```ts
|
||
if (dataScope.type === "grade_managed") {
|
||
const gradeId = dataScope.gradeIds[0] // ⚠️ 仅取第一个
|
||
}
|
||
if (dataScope.type === "class_members" || dataScope.type === "class_taught") {
|
||
const classId = dataScope.classIds[0] // ⚠️ 仅取第一个
|
||
}
|
||
if (dataScope.type === "children") {
|
||
const childId = dataScope.childrenIds[0] // ⚠️ 仅取第一个孩子
|
||
}
|
||
```
|
||
- **后果**:
|
||
- **家长**有多个孩子在不同班级/年级时,只能看到第一个孩子的定向公告,第二个孩子的班主任通知完全不可见——直接违反 K12 家长端核心诉求。
|
||
- **年级主任**管理多个年级时,只能看到第一个年级的公告。
|
||
- **教师**任课多个班级时,只能看到第一个班级的公告。
|
||
- **违反规则**:项目规则"Parent routes must include permission checks with both `parentId` and `studentId`" 与"data-access 层结合当前用户权限过滤"。
|
||
- **根因**:`getAnnouncements` 的 `audience` 参数设计为单值 `{ gradeId?, classId? }`,不支持多值;`resolveAudience` 为迁就该签名做了截断。
|
||
|
||
### 2.3 【P0 · 越权写】置顶/已读 Action 缺少资源所有权二次校验
|
||
|
||
- **位置**:[src/modules/announcements/actions.ts:335-376](file:///e:/Desktop/CICD/src/modules/announcements/actions.ts)
|
||
- **问题**:
|
||
- `toggleAnnouncementPinAction` 仅校验 `ANNOUNCEMENT_MANAGE`,未校验公告是否存在、未校验调用者是否为该公告作者或管理员范围。
|
||
- `markAnnouncementAsReadAction` 仅校验 `ANNOUNCEMENT_READ`,未校验该公告是否对当前用户可见(即未结合 2.1 的受众过滤)。任意用户可对任意公告 ID(包括草稿、他人班级公告)写入已读记录,污染 `announcement_reads` 表。
|
||
- **后果**:数据库完整性被破坏;统计 `readCount` 失真;为后续基于已读率的分析埋下错误数据。
|
||
- **违反规则**:项目规则"Server Action 二次校验"。
|
||
- **根因**:Action 层信任了 `requirePermission` 的角色校验,未做资源级(resource-level)授权。
|
||
|
||
### 2.4 【P1 · i18n 违规】Actions 返回英文硬编码消息
|
||
|
||
- **位置**:[src/modules/announcements/actions.ts](file:///e:/Desktop/CICD/src/modules/announcements/actions.ts) 全文
|
||
- **问题**:所有 Action 返回的 `ActionState.message` 均为英文字符串:
|
||
- `"Announcement created"` / `"Announcement updated"` / `"Announcement deleted"`
|
||
- `"Announcement published"` / `"Announcement archived"`
|
||
- `"Announcement not found"` / `"Invalid form data"` / `"Unexpected error"`
|
||
- `"Pin status toggled"` / `"Announcement marked as read"`
|
||
- 这些 message 通过 `toast.success(res.message)` / `toast.error(res.message)` 直接展示给用户(见 [announcement-detail.tsx:80,100,115](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-detail.tsx) 与 [announcement-form.tsx:80,88](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-form.tsx))。
|
||
- **后果**:中文用户在创建/发布/删除公告后看到英文 Toast,i18n 字典中已定义的 `messages.created` / `messages.updated` 等翻译键完全未使用。
|
||
- **违反规则**:项目规则"所有用户可见文本必须适配 i18n" + "Server Action 返回值统一采用 `ActionState<T>` 类型"(隐含 message 应可本地化)。
|
||
- **根因**:Actions 在 try 块内同步返回字符串,未通过 `getTranslations("announcements")` 获取本地化文案;i18n 字典定义了键但 Action 未消费。
|
||
|
||
### 2.5 【P1 · 死代码】`AnnouncementDetail` 管理分支与 `getAnnouncementReadStatusAction` 无调用方
|
||
|
||
- **位置**:
|
||
- [src/modules/announcements/components/announcement-detail.tsx:165-200](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-detail.tsx)(`canManage` 为 true 时的发布/归档/删除/置顶/编辑按钮组)
|
||
- [src/modules/announcements/actions.ts:381-391](file:///e:/Desktop/CICD/src/modules/announcements/actions.ts)(`getAnnouncementReadStatusAction`)
|
||
- **问题**:
|
||
- 全仓搜索 `AnnouncementDetail` 的使用方,仅 [src/app/(dashboard)/announcements/[id]/page.tsx:34-38](file:///e:/Desktop/CICD/src/app/(dashboard)/announcements/[id]/page.tsx) 一处,且 `canManage={false}`。管理端 `/admin/announcements/[id]` 直接渲染 `AnnouncementForm`(编辑模式),**没有管理端详情页**。
|
||
- 全仓搜索 `getAnnouncementReadStatusAction`,**零调用方**。该 Action 返回 `Record<string,boolean>`,本应用于列表页批量标记已读/未读,但列表页从未调用。
|
||
- **后果**:
|
||
- 管理员无法在 UI 中执行发布/归档/删除/置顶操作(除非进入编辑表单),严重限制了管理端可用性。
|
||
- `announcement.readCount` 字段在 `AnnouncementDetail` 中展示,但因 `canManage` 永远为 false,**用户永远看不到已读人数**——已读统计功能在 UI 层完全不可见。
|
||
- 列表页公告卡片没有"已读/未读"视觉区分,已读回执的数据无法驱动 UI。
|
||
- **违反规则**:项目规则"避免 backwards-compatibility hacks ... 如果确定未使用,应完全删除" + "识别四个角色共用的 UI 块"。
|
||
- **根因**:管理端路由设计遗漏了详情视图;已读状态查询 Action 未被列表组件消费。
|
||
|
||
### 2.6 【P1 · 耦合】组件直接 import actions,未通过 Context/Provider 注入
|
||
|
||
- **位置**:
|
||
- [announcement-card.tsx:13](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-card.tsx):`import { toggleAnnouncementPinAction } from "../actions"`
|
||
- [announcement-detail.tsx:25-31](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-detail.tsx):`import { archiveAnnouncementAction, deleteAnnouncementAction, markAnnouncementAsReadAction, publishAnnouncementAction, toggleAnnouncementPinAction } from "../actions"`
|
||
- [announcement-form.tsx:21](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-form.tsx):`import { createAnnouncementAction, updateAnnouncementAction } from "../actions"`
|
||
- **问题**:组件硬编码依赖具体 Server Action,无法在不修改组件代码的前提下替换为 mock 实现。
|
||
- **后果**:
|
||
- 组件不可单元测试(必须 mock 整个 `../actions` 模块)。
|
||
- 无法为不同角色注入不同实现(如家长端只读、教师端可编辑班级公告)。
|
||
- 未来若要将公告组件复用于"班级空间"或"家长端聚合页",必须重写组件。
|
||
- **违反规则**:用户要求"完全解耦:通过定义 TypeScript 接口抽象数据依赖,使用 React Context 注入数据服务,模块内部组件绝不直接 import 其他业务模块的 actions 或 data-access"。
|
||
- **根因**:组件设计未遵循依赖注入原则。
|
||
|
||
### 2.7 【P1 · 耦合】`AnnouncementForm` 硬编码路由跳转
|
||
|
||
- **位置**:[announcement-form.tsx:82,217](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-form.tsx)
|
||
- **问题**:表单提交成功后 `router.push("/admin/announcements")`,取消按钮也跳转到 `/admin/announcements`。
|
||
- **后果**:表单无法在管理端以外的场景复用(如教师端发布班级公告、嵌入到班级详情页的快速发布公告入口)。
|
||
- **违反规则**:用户要求"组合优先 ... 逻辑复用一律抽取为自定义 hooks" + "最大化复用"。
|
||
- **根因**:表单未通过 `onSuccess` / `onCancel` 回调或 `successHref` prop 解耦导航。
|
||
|
||
### 2.8 【P1 · 业务逻辑位置】`resolveTargetUserIds` 放在 actions.ts
|
||
|
||
- **位置**:[src/modules/announcements/actions.ts:48-66](file:///e:/Desktop/CICD/src/modules/announcements/actions.ts)
|
||
- **问题**:受众解析 + 用户 ID 聚合是纯业务逻辑(无 I/O 副作用之外的逻辑),却放在 Server Action 文件中,与 Action 编排逻辑混杂。
|
||
- **后果**:
|
||
- 无法独立单元测试(必须 mock `getAllUserIds` / `getStudentIdsByClassId` 等跨模块 data-access)。
|
||
- 与 `data-access.ts` 中的 `resolveAudience` 形成两套受众解析逻辑,职责重叠。
|
||
- **违反规则**:项目规则"可测试性:数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks,与 UI 分离" + "Server Actions / Data Access 模块:建议 ≤ 800 行 ... 超过应考虑拆分"。
|
||
- **根因**:actions.ts 既承担 HTTP 编排又承担业务规则,未分离 service 层。
|
||
|
||
### 2.9 【P1 · 性能】`toggleAnnouncementPin` 与 `markAnnouncementAsRead` 非原子操作
|
||
|
||
- **位置**:[src/modules/announcements/data-access.ts:210-224,234-250](file:///e:/Desktop/CICD/src/modules/announcements/data-access.ts)
|
||
- **问题**:
|
||
- `toggleAnnouncementPin`:先 `SELECT isPinned`,再 `UPDATE`。两次 DB 往返,且在并发场景下存在 lost update(两个管理员同时切换会得到错误结果)。
|
||
- `markAnnouncementAsRead`:先 `SELECT id`,再 `INSERT`。已有唯一索引保证幂等,但多一次 SELECT 浪费往返。
|
||
- **后果**:高并发时数据不一致;DB 负载翻倍。
|
||
- **违反规则**:项目规则"性能:优先使用 React Server Components"(隐含高效数据访问)。
|
||
- **根因**:未使用 Drizzle 的 `sql` 表达式或 `onDuplicateKeyUpdate`/`INSERT IGNORE` 语义。
|
||
|
||
### 2.10 【P1 · 错误处理】`handleActionError` 吞错误上下文
|
||
|
||
- **位置**:[src/modules/announcements/actions.ts:34-40](file:///e:/Desktop/CICD/src/modules/announcements/actions.ts)
|
||
- **问题**:
|
||
```ts
|
||
function handleActionError(e: unknown): ActionState<never> {
|
||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||
if (e instanceof Error) return { success: false, message: e.message }
|
||
return { success: false, message: "Unexpected error" }
|
||
}
|
||
```
|
||
- 未 `console.error` 记录错误堆栈,生产环境无法定位故障。
|
||
- 直接把 `e.message` 返回给前端,可能泄露内部错误信息(如 SQL 错误)。
|
||
- "Unexpected error" 为英文硬编码。
|
||
- **后果**:可观测性差;安全信息泄露风险。
|
||
- **违反规则**:项目规则"错误与边界处理" + "i18n 就绪"。
|
||
- **根因**:错误处理未与日志/埋点/i18n 集成。
|
||
|
||
### 2.11 【P2 · a11y】置顶按钮嵌套在 `<Link>` 内的键盘交互问题
|
||
|
||
- **位置**:[announcement-card.tsx:80-92,116-121](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-card.tsx)
|
||
- **问题**:`AnnouncementCard` 在有 `href` 时用 `<Link>` 包裹整个卡片,同时卡片内的"置顶"按钮是一个 `<button>`。`handleTogglePin` 调用 `e.preventDefault()` + `e.stopPropagation()` 处理鼠标点击,但:
|
||
- 键盘聚焦到置顶按钮后按 `Enter`,部分浏览器会同时触发外层 `<a>` 的导航。
|
||
- 屏幕阅读器会朗读"链接 标题",但置顶按钮的 `aria-label` 在链接上下文中语义模糊。
|
||
- **后果**:键盘用户可能误跳转;a11y 不达标。
|
||
- **违反规则**:项目规则"可访问性(a11y):语义化标签、ARIA 属性、键盘导航"。
|
||
- **根因**:交互按钮不应嵌套在导航链接内;应使用"卡片头部可点击 + 操作按钮独立"的布局。
|
||
|
||
### 2.12 【P2 · 类型不安全】`mapRow` 内联对象类型与 schema 脱钩
|
||
|
||
- **位置**:[src/modules/announcements/data-access.ts:23-53](file:///e:/Desktop/CICD/src/modules/announcements/data-access.ts)
|
||
- **问题**:`mapRow` 的参数类型是手写的内联对象,未使用 Drizzle 推导类型 `typeof announcements.$inferSelect`。
|
||
- **后果**:schema 变更(如新增字段)时,`mapRow` 不会在编译期报错,导致类型漂移。
|
||
- **违反规则**:项目规则"TypeScript 严格模式 ... 函数返回值必须显式标注"。
|
||
- **根因**:未利用 Drizzle 的类型推导能力。
|
||
|
||
### 2.13 【P2 · i18n 字典冗余/缺失并存】
|
||
|
||
- **位置**:[src/shared/i18n/messages/zh-CN/announcements.json](file:///e:/Desktop/CICD/src/shared/i18n/messages/zh-CN/announcements.json)
|
||
- **问题**:
|
||
- 已定义但未使用的键:`messages.created` / `messages.updated` / `messages.deleted` / `messages.published` / `messages.archived` / `messages.notFound` / `messages.createFailed` / `messages.invalidForm` / `messages.markedRead`(共 9 个死键,因 actions 未消费)。
|
||
- 缺失的键:`description.detail`(详情页描述)、`description.create`(创建 Dialog 描述)。
|
||
- **后果**:i18n 字典维护成本上升;新增页面时找不到对应键。
|
||
- **违反规则**:项目规则"所有用户可见文本必须适配 i18n"。
|
||
- **根因**:i18n 键与代码未做同步校验。
|
||
|
||
### 2.14 【P2 · 死分支】`detailHrefBuilder` prop 未被使用
|
||
|
||
- **位置**:[announcement-list.tsx:39,70-74](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-list.tsx)
|
||
- **问题**:`AnnouncementList` 同时支持 `detailHrefPrefix`(字符串前缀)和 `detailHrefBuilder`(函数)两种 prop,但全仓搜索 `detailHrefBuilder` 的传入方为零(所有调用方都使用 `detailHrefPrefix`)。
|
||
- **后果**:死代码增加维护负担。
|
||
- **违反规则**:项目规则"避免 backwards-compatibility hacks"。
|
||
- **根因**:V3 重构引入 `detailHrefPrefix` 后未清理旧 prop。
|
||
|
||
### 2.15 【P2 · 重复骨架屏】用户端与管理端 loading.tsx 完全重复
|
||
|
||
- **位置**:
|
||
- [src/app/(dashboard)/announcements/loading.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/announcements/loading.tsx)
|
||
- [src/app/(dashboard)/admin/announcements/loading.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/announcements/loading.tsx)
|
||
- **问题**:两个文件几乎逐行重复(仅管理端多一个"新建公告"按钮骨架),未抽取共享骨架屏组件。
|
||
- **后果**:UI 调整需改两处。
|
||
- **违反规则**:项目规则"Shared components must be extracted when page duplication exceeds 90%"。
|
||
- **根因**:未识别到骨架屏也是可复用 UI 块。
|
||
|
||
### 2.16 【P2 · 无分页 UI】`getAnnouncements` 支持分页但 UI 未消费
|
||
|
||
- **位置**:[data-access.ts:55-112](file:///e:/Desktop/CICD/src/modules/announcements/data-access.ts) 支持 `page` / `pageSize`;[announcement-list.tsx](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-list.tsx) 无分页控件。
|
||
- **问题**:列表页默认 `pageSize=20`,超过 20 条公告时静默截断,用户无法翻页。
|
||
- **后果**:历史公告不可访问;K12 学校一学期公告数通常 > 20。
|
||
- **违反规则**:用户要求"可扩展性:配置驱动设计"。
|
||
- **根因**:分页参数未贯穿到 UI。
|
||
|
||
### 2.17 【P2 · 表单与 Dialog 行为冲突】
|
||
|
||
- **位置**:[admin-announcements-view.tsx:57-64](file:///e:/Desktop/CICD/src/modules/announcements/components/admin-announcements-view.tsx)
|
||
- **问题**:`AdminAnnouncementsView` 在 Dialog 中渲染 `AnnouncementForm`(创建模式)。但 `AnnouncementForm` 的 Cancel 按钮 `router.push("/admin/announcements")` 会触发整页跳转,而不是关闭 Dialog。提交成功后也是 `router.push` 而非 `onSuccess` 回调。
|
||
- **后果**:用户体验割裂(Dialog 内按钮触发路由跳转);`handleOpenChange` 中的 `router.refresh()` 与表单跳转重复。
|
||
- **违反规则**:项目规则"组合优先 ... 严禁使用继承或深层嵌套 HOC"。
|
||
- **根因**:表单未与容器解耦。
|
||
|
||
---
|
||
|
||
## 三、行业差距对比
|
||
|
||
参考 Google Classroom、钉钉教育、企业微信家校通、飞书校园版、PowerSchool 等主流 K12 产品的公告/通知模块,对比差距如下:
|
||
|
||
| 维度 | 行业主流实践 | 当前实现 | 差距影响 |
|
||
|------|------------|---------|---------|
|
||
| **多受众定向** | 支持多班级/多年级/多角色组合发布(如"高三1班+2班家长") | 仅支持单年级或单班级 | 年级组长需重复发布 N 次 |
|
||
| **富文本/附件** | 富文本编辑器 + 附件(PDF 通知、图片) | 纯文本 `whitespace-pre-wrap` | 学校正式通知无法排版、无法附带 PDF |
|
||
| **分类/标签** | 学科、活动、安全、家长信等分类筛选 | 仅按 status 筛选 | 家长在海量公告中找不到关注项 |
|
||
| **定时发布** | 选择未来时间自动发布 | schema 有 `publishedAt` 但 UI 未消费 | 管理员需手动踩点发布 |
|
||
| **到期/置顶** | 自动到期 + 多级优先级(紧急/普通) | 仅 pinned 布尔 | 紧急通知与普通通知无差异 |
|
||
| **已读统计仪表盘** | 管理端列表展示每条公告已读率、未读名单、可一键催读 | `readCount` 字段存在但 UI 未展示 | 管理员无法评估公告触达效果 |
|
||
| **草稿预览** | 编辑时预览发布后效果 | 无预览 | 发布前无法验证排版 |
|
||
| **批量操作** | 列表多选 + 批量归档/删除 | 逐条操作 | 学期末清理 50 条公告需 50 次点击 |
|
||
| **搜索** | 标题/正文全文搜索 | 无搜索 | 历史公告无法检索 |
|
||
| **Dashboard 集成** | 首页"最新公告"Widget + 未读红点 | 仅家长端有快速入口链接 | 用户必须主动进入公告页 |
|
||
| **通知点击回跳** | 点击通知直达公告详情并自动已读 | 通知 actionUrl 指向详情页,但详情页无受众校验 | 通知点击可能触发越权 |
|
||
| **多语言/多角色文案** | 同一公告对家长/学生/教师展示不同侧重点 | 同一文案对所有角色 | 家长看到教师内部用语 |
|
||
| **无障碍** | 列表语义化 `<ul>`/`<li>`、键盘可达 | `<div>` + `<Link>` 包裹按钮 | 屏幕阅读器用户导航困难 |
|
||
| **错误恢复** | 失败自动重试 + 离线草稿 | 失败仅 Toast 提示 | 网络波动时内容丢失 |
|
||
|
||
**核心差距**:当前实现停留在"CRUD + 状态机"的最小可用形态,缺少 K12 公告模块的"触达-反馈-统计"闭环。其中"已读统计不可见"和"无富文本/附件"是 K12 学校最痛的两个缺口。
|
||
|
||
---
|
||
|
||
## 四、改进优先级建议
|
||
|
||
### P0(必须立即修复 · 安全与数据正确性)
|
||
|
||
| # | 问题 | 改进方向 |
|
||
|---|------|---------|
|
||
| P0-1 | 详情页越权读取 | 新增 `getAnnouncementByIdForUser(id, userId, dataScope)` data-access 函数,结合 `status="published"` 与受众过滤;路由层调用此函数,未命中返回 `notFound()` |
|
||
| P0-2 | `resolveAudience` 多孩子/多年级截断 | 将 `audience` 参数升级为 `{ gradeIds: string[]; classIds: string[] }`;`getAnnouncements` 用 `inArray` 查询;`resolveAudience` 返回完整数组而非首个 |
|
||
| P0-3 | 置顶/已读 Action 缺资源级校验 | `toggleAnnouncementPinAction` 校验公告存在;`markAnnouncementAsReadAction` 调用新增的 `getAnnouncementByIdForUser` 校验可见性后再写入 |
|
||
|
||
### P1(高优先级 · 架构与可维护性)
|
||
|
||
| # | 问题 | 改进方向 |
|
||
|---|------|---------|
|
||
| P1-1 | Actions 返回英文硬编码 | 引入 `getTranslations("announcements")`,所有 `ActionState.message` 改用 i18n 键;新增 `messageKey` 字段或直接返回本地化字符串 |
|
||
| P1-2 | 死代码:管理端详情分支 / `getAnnouncementReadStatusAction` | 新增 `/admin/announcements/[id]/view` 详情页消费 `AnnouncementDetail canManage=true`;列表组件调用 `getAnnouncementReadStatusAction` 展示已读/未读角标;或删除死分支 |
|
||
| P1-3 | 组件直接 import actions | 新建 `announcements-service-context.tsx`,定义 `AnnouncementsService` 接口(含 `togglePin` / `publish` / `archive` / `delete` / `markRead` / `create` / `update` 方法签名),用 Provider 注入默认实现;组件 `useContext` 消费 |
|
||
| P1-4 | `AnnouncementForm` 硬编码路由 | 新增 `onSuccess?` / `onCancel?` 回调 prop,回调优先于 `router.push`;默认 `successHref` prop 兜底 |
|
||
| P1-5 | `resolveTargetUserIds` 放 actions.ts | 下沉到 `data-access.ts` 的 `resolveAnnouncementTargetUserIds(announcement)` 纯函数;actions.ts 仅做编排 |
|
||
| P1-6 | 非原子 toggle / markRead | `toggleAnnouncementPin` 改为 `UPDATE ... SET is_pinned = NOT is_pinned`;`markAnnouncementAsRead` 改为 `INSERT ... ON DUPLICATE KEY UPDATE id=id`(Drizzle 的 `onDuplicateKeyUpdate`) |
|
||
| P1-7 | `handleActionError` 吞错误 | 新增 `console.error` + `trackEvent("announcement.action_error")`;message 走 i18n;不向客户端返回原始 `e.message` |
|
||
| P1-8 | 表单与 Dialog 行为冲突 | 表单通过 `onSuccess` 回调关闭 Dialog;移除表单内的 `router.push` |
|
||
|
||
### P2(中优先级 · 体验与工程化)
|
||
|
||
| # | 问题 | 改进方向 |
|
||
|---|------|---------|
|
||
| P2-1 | a11y:按钮嵌套在 Link 内 | 重构 `AnnouncementCard`:卡片本身为 `<Link>`,置顶按钮用绝对定位 + `z-index` 独立于链接,或改用 `<article>` + 独立链接 + 独立按钮的语义结构 |
|
||
| P2-2 | `mapRow` 类型脱钩 | 改用 `typeof announcements.$inferSelect` 推导;移除手写内联类型 |
|
||
| P2-3 | i18n 死键 / 缺键 | 删除未使用的 9 个 `messages.*` 死键(或随 P1-1 启用);补 `description.detail` / `description.create` |
|
||
| P2-4 | `detailHrefBuilder` 死 prop | 删除该 prop,仅保留 `detailHrefPrefix` |
|
||
| P2-5 | 重复骨架屏 | 抽取 `AnnouncementListSkeleton` 共享组件到 `components/` |
|
||
| P2-6 | ✅ 已实施 | 无分页 UI → 新增 `AnnouncementPagination` 组件;`getUserAnnouncementsPageData` 返回 `{ items, total, page, pageSize }` |
|
||
| P2-7 | ✅ 已实施 | 无测试 → 新增 `schema.test.ts`(18 测试,`refineAudience` 矩阵)、`is-announcement-visible.test.ts`(15 测试,纯函数含多孩子场景)、`announcement-card.test.tsx`(16 测试,交互 + a11y) |
|
||
|
||
### 中长期(P3 · 功能演进,对应行业差距)
|
||
|
||
| # | 方向 | 说明 |
|
||
|---|------|------|
|
||
| P3-1 | 富文本 + 附件 | 接入 `files` 模块(已支持 `targetType="announcement"`);引入轻量富文本编辑器(如 Tiptap) |
|
||
| P3-2 | 分类/标签 | 新增 `announcement_tags` 表 + 列表筛选;预置 K12 分类(学科/活动/安全/家长信) |
|
||
| P3-3 | 定时发布 | 表单增加 `publishedAt` 日期选择器;新增 cron 校验到点自动 `status="published"` |
|
||
| P3-4 | 已读统计仪表盘 | 管理端列表展示已读率柱状图;详情页展示未读名单 + 一键催读(触发 `sendBatchNotifications`) |
|
||
| P3-5 | 批量操作 | 列表多选 + 批量归档/删除 Action |
|
||
| P3-6 | 全文搜索 | 接入 `app/api/search` 已有的全局搜索(当前已支持 announcement 类型) |
|
||
| P3-7 | Dashboard 集成 | 新增 `AnnouncementsWidget`(最新 3 条 + 未读红点),挂载到各角色 Dashboard |
|
||
| P3-8 | 草稿预览 | 表单"预览"按钮展开只读视图 |
|
||
|
||
---
|
||
|
||
## 五、架构图同步说明
|
||
|
||
本次审计发现 `004_architecture_impact_map.md` §2.16 与 `005_architecture_data.json#announcements` 存在以下遗漏,需在实施后同步更新:
|
||
|
||
1. **新增节点**:
|
||
- `data-access.resolveAnnouncementTargetUserIds`(P1-5 下沉的纯函数)
|
||
- `data-access.getAnnouncementByIdForUser`(P0-1 新增的受众过滤读取)
|
||
- `AnnouncementsServiceContext`(P1-3 新增的依赖注入 Provider)
|
||
- `AnnouncementListSkeleton`(P2-5 抽取的共享骨架屏)
|
||
- `AnnouncementPagination`(P2-6 新增的分页组件)
|
||
|
||
2. **删除节点**:
|
||
- `actions.getAnnouncementReadStatusAction`(若 P1-2 选择删除而非启用)
|
||
- `AnnouncementList.detailHrefBuilder` prop(P2-4 删除)
|
||
|
||
3. **修改节点**:
|
||
- `GetAnnouncementsParams.audience` 类型从 `{ gradeId?; classId? }` 改为 `{ gradeIds: string[]; classIds: string[] }`
|
||
- `AnnouncementDetail` 的 `canManage` 分支启用记录(新增管理端详情页后)
|
||
- `actions.ts` 行数变化(下沉 `resolveTargetUserIds` 后减少)
|
||
- `data-access.ts` 行数变化(新增函数后增加)
|
||
|
||
4. **新增依赖关系**:
|
||
- `announcements → files`(P3-1 附件集成后)
|
||
- `announcements → dashboard`(P3-7 Widget 集成后)
|
||
|
||
5. **已知问题清单更新**:
|
||
- 新增"P0-1 详情页越权"(修复后标记 ✅)
|
||
- 新增"P0-2 多孩子截断"(修复后标记 ✅)
|
||
- 新增"P0-3 资源级校验缺失"(修复后标记 ✅)
|
||
- 新增"P1-1 Actions i18n"(修复后标记 ✅)
|
||
|
||
---
|
||
|
||
## 附:实施清单(与上述优先级一一对应)
|
||
|
||
实施将按 P0 → P1 → P2 → P3 顺序推进,P3 为中长期演进,本次实施聚焦 P0/P1/P2,P3 中富文本/附件/Dashboard 集成将择期推进。每完成一项同步更新架构图与运行 `npm run lint` + `npx tsc --noEmit` 验证。
|