9.8 KiB
PRD 差距收尾 — 设计文档
日期:2026-07-07 范围:PRD v1.0 审计后剩余的全部 ⚠️ 部分实现 / ❌ 未实现项 + 1 个确认 bug,共 12 项,拆为 8 个工作包(WP)。 执行方式:实现工作全部派发给
deepseek/deepseek-v4-pro子代理,每个 WP 独立验收,最后统一构建验证。
背景
基于 6 路并行代码审计(2026-07-07),PRD 的 P0/P1 主体功能已完成。剩余缺口:
| 类别 | 项 |
|---|---|
| Bug | 今日出勤率恒为 0 |
| ⚠️ 部分实现 | 钉钉兜底匹配、档案脱敏、多班型封面、PDF 专业课对比、面板角色差异化、organization 残留、增量同步、账单外部推送 |
| ❌ 未实现 | 入住 enrollment 预填、入住时间线、排课单双周 |
WP1 — Bug:今日出勤率恒为 0
问题:apps/server/src/dashboard/dashboard.service.ts getStats() 在 ~L92 计算了 todayAttendanceRate,但 L153-173 的 return 对象漏掉该字段;前端 apps/admin/src/pages/Dashboard/index.tsx:386 读 stats?.todayAttendanceRate 恒为 undefined,指标卡显示 0。
修复:return 对象补 todayAttendanceRate。无其他改动。
验收:GET /dashboard/stats 响应含 todayAttendanceRate 字段(有当日考勤数据时为非零字符串)。
WP2 — 钉钉考勤兜底匹配
现状:apps/server/src/attendance/attendance.service.ts autoMatchDingRecords() 仅按姓名匹配;PRD 23.3 要求手机号、身份证号兜底。
设计:
- 匹配链(顺序执行,命中即停):
students.resource_user_id = raw.ding_user_id- 经 users 同步表由 dingUserId 取 phone →
students.phone精确匹配 - 同上取到的身份信息 →
students.id_card精确匹配(若钉钉侧有) - 姓名精确匹配(现有逻辑保留,作为最后一级)
- 任一级命中多个候选学生 → 不自动绑定,保持
match_status=待匹配(人工处理页兜底)。 ding_attendance_raw实体新增可空phone列;打卡事件落库时若能从已同步 User 拿到手机号则冗余写入,便于人工匹配页展示与排查。SQLite 开发库用synchronize自动加列;不需要手写迁移(生产 MySQL 上线时统一走 schema 同步流程)。- 人工匹配 API(POST /ding-attendance-raw/:id/match)不变。
验收:构造 4 条 raw 记录(分别只能由 resourceUserId / phone / idCard / name 命中),autoMatch 后均绑定正确 student;构造重名两学生 → 该记录保持待匹配。
WP3 — 学生档案三项
3a 敏感信息脱敏(PRD 1.3)
现状:apps/admin/src/pages/Students/index.tsx 已实现脱敏+二次确认+后端日志;apps/admin/src/components/StudentProfileContent/index.tsx 直接明文展示 phone/idNumber。
设计:复用 Students 页的既有模式(同一确认弹窗文案、同一后端敏感查看日志端点),StudentProfileContent 中 phone/idNumber 默认脱敏(138****1234 / 110***********1234),点击"查看"→ Modal.confirm → 调日志端点 → 显示明文。不新造第二套脱敏组件:若 Students 页逻辑是内联的,抽为共享工具/组件后两处复用。
3b 多班型封面(PRD 2.1)
现状:档案封面 enrollment 卡片只展示前 2 个。
设计:改为全部展示,卡片区 flex-wrap 布局;3+ 班型时自动换行。
3c PDF 报表专业课对比(PRD 2.2)
现状:apps/server/src/archive/archive-report.service.ts buildExamDetail 仅渲染文化课考试。
设计:镜像文化课渲染逻辑,增加专业课成绩独立表格 + 趋势区块;按 exam 记录的课程类别(文化课/专业课)分组,专业课组为空时该区块不渲染(不出空表)。
验收:3a 档案页 phone/idNumber 默认脱敏、确认后显示且 operation_logs 有记录;3b 造 3 个 enrollment 的学生封面全部可见;3c 有专业课成绩的学生报表 HTML 含专业课对比表,无专业课成绩的学生报表不含空区块。
WP4 — 数据面板角色差异化(PRD 23.6)
现状:Dashboard 仅有 CampusScope 部门过滤,无角色差异化。
设计:
- 后端
getStats()(及 class-attendance-ranking 等端点)注入当前用户:- 用户为班主任类角色(存在 ClassTeacher 关联且非 super_admin/staff 系管理角色)→ 考勤指标、班级出勤排行仅统计其所带班级(ClassTeacher → classId 过滤)。
- 用户无
bill/deposit相关权限 → return 对象不含monthlyIncome、pendingDeposits、incomeTrend、billStats字段。
- 前端指标卡/图表按字段有无条件渲染:字段 undefined 即不渲染该卡片/图。
- super_admin 与 staff 系角色行为完全不变。
- 判定依据用现有 RBAC 权限节点(请求上下文里已有用户权限),不新增权限节点。
验收:以班主任账号请求 /dashboard/stats → 无收入/押金字段、考勤数只含本班;超管请求 → 字段齐全,与改动前一致。
WP5 — organization → tenant_id 收尾(PRD 1.1)
现状:students.tenant_id 外键与筛选已生效,旧 organization 文本列共存。
设计(干净切换):
- 回填逻辑放在 StudentsService(或专用 backfill service)的
onModuleInit中幂等执行(项目约定:synchronize: true+ 服务内幂等 seed,参照expense-types.service.ts的seedDefaults):对organization非空且tenant_id为空的学生,按名称 find-or-create Tenant(新建的给默认颜色),回填tenantId。注意:organization列从 entity 删除后 TypeORM synchronize 不会自动删物理列(SQLite/MySQL 均保留孤列),回填需在删除 entity 字段前用 raw query 读取该列,保证幂等重跑安全。 - 迁移完成后删除
organization:entity 字段、DTO、导入导出模板列、前端表单/表格列全部清除。 - Excel 导入模板中原"机构"列改为按 Tenant 名称解析(find-or-create 同上),保持导入体验不回退。
验收:迁移后无 organization 引用(grep 为零,实体除外的历史注释可留);导入含机构名的 Excel 仍能正确挂 tenant;学生列表机构筛选正常。
WP6 — 第三方集成两项
6a 增量同步(INT.3)
约束:钉钉/企微组织架构 API 不支持"按变更时间查询",真增量不可行。
设计:全量拉取 + 本地 diff 写入:
syncAll()拉取后与库内记录逐条比对,仅对有实际字段变化的记录执行 update,新记录 insert,无变化 skip。- 同步日志(sync_log)记录
created/updated/skipped三个数量;lastSyncAt保留用于展示。 - 移除
_lastSyncAt假形参(要么真用于日志,要么删除),不留误导性签名。
6b 账单外部推送(10.2)
设计:
- 账单确认(confirmed)时触发(draft 不推,避免打扰):对账单学生查
resource_user_id,钉钉侧发工作通知、企微侧发应用消息(走 integration 模块现有 token 机制,新增发消息方法)。 - 凭据未配置或学生无 resourceUserId → 静默跳过,仅站内 SSE 通知(现状保留),不抛错不阻塞账单流程。
- 推送结果写操作日志(module=bills, action=notify)。
验收:6a 连续两次同步,第二次日志 skipped≈全部、updated=0;6b 无凭据环境下确认账单不报错且站内通知正常,推送代码路径有单测覆盖(mock integration service 验证调用参数)。
WP7 — 入住管理两项
7a enrollment 预填(8.1)
入住弹窗选中学生后,调现有档案/enrollment 查询接口拉取该生当前在读 enrollment,在表单内只读展示班级/班型/班主任提示信息。不改数据模型、不落库。
7b 入住历史时间线(8.3)
入住列表行加"历史"按钮 → Drawer 内 AntD Timeline 按时间倒序展示该学生全部 occupancy 记录(入住/换房/退住节点,含房间号、日期、退住原因)。数据用现有 occupancies 按 studentId 查询的 API(如无该筛选参数则补上)。
验收:7a 选择有 enrollment 的学生显示班级提示、无 enrollment 学生不报错;7b 多次入住的学生时间线节点完整有序。
WP8 — 排课单双周
设计:
class_schedule新增week_parity列:'all' | 'odd' | 'even',默认'all';奇偶按 ISO 8601 周数判定。- 冲突检测(
apps/server/src/schedules/schedules.service.ts):同教室同 weekDay 时间重叠时,oddvseven不冲突;all与任何值冲突。RENTAL 类型视为all。 - 前端排课表单加"周次"选择(默认每周);周视图格子对 odd/even 排课显示"单/双"角标;月视图按具体日期的 ISO 周奇偶过滤显示。
- 排课→考勤自动生成(
attendance.service.tsgenerateFromSchedules):按目标日期 ISO 周奇偶跳过不匹配的排课。 - 班级课表(/classes/:id/schedule)响应带 weekParity 字段,前端同样标注。
验收:同教室同时段创建 odd+even 两条排课成功、再建 all 报冲突;单周排课在双周日期不生成考勤记录;周视图角标正确。
横切约束
- 所有新增写操作接入 OperationLogsService(沿现有各 controller 模式)。
- 不引入新依赖;ISO 周数计算用 dayjs 现有插件或自实现纯函数。
- 各 WP 不跑项目级 lint/test/build——由主控最后统一验证。
- 前端遵循现有页面结构与 AntD 6 组件用法;后端遵循 entity/dto/service/controller 模块结构。
工作包依赖
全部 WP 相互独立、可并行。WP8 内部排课字段与考勤生成改动同包完成,无跨包依赖。
测试策略
- WP2、WP6b、WP8 冲突检测:Jest 单测(新增/修改的 service 方法)。
- 其余 WP:以 API 响应/页面行为验收,主控统一
turbo build+ 相关单测。