更新叫乘过程中状态。

This commit is contained in:
zfjia committed 2026-11-14 13:01:21 +08:00
1 parent ba77386575
commit f583bb5932
79 files changed
+10521 -41

No files matched your search

@@ -0,0 +1,227 @@
# 11 应用端 UI 开发计划
> **唯一视觉与交互基线**:`docs/公寓端/ui/duty-console-v2.html`(单文件、零依赖、780 行;下文行号均指该文件)。
> 追溯:`docs/10` §9 表 9-1~9-6(阶段)、§11.2 表 11-1(优先级);`docs/应用端/07` 表 7-2(D-1~D-3 子批次)、表 7-3(验证项)、表 7-4(页面顺序)。
> 契约依据:`docs/应用端/02` 表 2-5(`entry-duty` 目录)、表 2-7(D1~D10 路由)、表 2-9(`duty:*` 权限点);`04` 表 4-2(`ryzt` 呈现)、表 4-6(命令矩阵)、表 4-7(命令请求响应)、表 4-12(`/ws/duty` 消息)。
> 流程依据:`docs/应用端/05` 图 5-1(任务到期 → 值班员处置)、图 5-4(临叫三路径)、表 5-5(告警判据)。
> **本文只做计划,不写业务代码、不建表改表**;任务编号 `T-FE-*` / `T-BE-*` 为工程内部编号,**不新增批次、不改 07 的 D-1~D-3 口径**。
## 0. 本文定位
三句话:
1. **范围**:只覆盖 UI 稿已经画出来的那一页 —— `D1 叫乘盯控台`(含 `D2` 抽屉里被 UI 吸收的 4 个行内动作)。`D3~D10` 本期不排,等出稿。
2. **粒度**:`07` 是批次级(D-1/D-2/D-3 与验收项),本文是任务级(每个组件、每个接口、每个人日),两者是"表 7-2 拆到工作项"的关系。
3. **口径**:UI 稿与 `04` 契约有 6 处对不上(§4),**先定缺口再开工**;缺口未定前对应任务标 `阻塞`,不允许"先按 UI 写着看"。
## 1. UI 基线盘点
### 1.1 六个区域
表 11-1 UI 稿的区域、组件与代码行号
| # | 区域 | 内容 | 行号 | 落地形态 |
| --- | --- | --- | --- | --- |
| 1 | 顶栏 `header.hd` | 标题+一句话副标题、系统状态徽标、时钟+班次、值班员、`刷新列表`/`临时叫乘`/`交接班`、界面版本切换 | `276~298` | `components/DutyHeader.vue` |
| 2 | 需马上处理 | 最多 3 张紧急卡片 + 计数 + 空态"当前没有需要马上处理的事项" | `300~305`、`531~547` | `components/DutyAlerts.vue` |
| 3 | 工具栏 | 筛选段 `需处理/全部/已完成`(带计数)、搜索框(房间/姓名/车次)、清空、`卡片显示/列表显示` | `307~321`、`516~530` | `components/DutyToolbar.vue` |
| 4 | 任务区 | 卡片布局(房间+徽标+姓名+车次+两个时刻+提示+主操作)与列表布局(6 列) | `559~584` | `DutyTaskCard.vue` / `DutyTaskRow.vue` |
| 5 | 吸底条 | `待叫乘/呼叫与通话/报警` 三个数 + "最近操作"一行 | `325~333` | `components/DutyFooter.vue` |
| 6 | 三个弹窗 + Toast | 停止叫乘(原因 4 选 1)、交接班(汇总未处理数)、临时叫乘(房间/时刻/车次)、4.5 s Toast | `335~365`、`639~721` | `components/dialogs/*`、`shared/Toast` |
### 1.2 状态与主操作
UI 用 8 个 `st` + 1 个 `alarm` 标志表达行状态(`370~371`、`446~468`),必须换算成 `04` 表 4-2 的 `ryzt` 组合,否则前后端各判一套,超时提示与按钮会漂移。
表 11-2 UI 状态 → 后端字段 → 主操作(**前端派生的唯一口径**)
| UI 状态 | 判定(后端字段) | 显示文字 | 主操作 | 对应命令 | 备注 |
| --- | --- | --- | --- | --- | --- |
| `todo` | `ryzt=0` 且 `jcsj` 非空 | ● 待叫乘 | 立即叫乘 | **无(`G-UI-01`)** | 提示"距叫乘还有 N 分钟" |
| `todo+overdue` | 同上且 `serverTime > jcsj` | ● 待叫乘(红) | 立即叫乘 | **无(`G-UI-01`)** | 提示"已过叫乘时间 N 分钟" |
| `calling` | `ryzt=1` | ● 呼叫中 | 确认接通 | **无(`G-UI-02`)** | 提示"正在呼叫,请等待对方接听" |
| `talking` | `ryzt=5` | ● 通话中 | 登记离寓 | `MARK_LEAVE` | 会议桥已建立 |
| `alarm`(`jc`) | `ryzt=2` | ! 叫乘报警 | 复叫 | `RE_CALL` | 30 s 无入会 |
| `alarm`(`ly`) | `ryzt=4` | ! 离寓报警 | 登记离寓 | `MARK_LEAVE` | `is_lybj=1` |
| `leave` | `ryzt=3` | ● 待离寓 | 登记离寓 | `MARK_LEAVE` | 已应答待离寓 |
| `in` | `finish_ly=0` 且 `ryzt` 为空/无 `jcsj` | ○ 已入寓 | 无 | — | "等待生成叫乘任务" |
| `stop` | `stoped=1` | × 已停止 | 重新叫乘 | `RE_CALL` | 主操作是"重新叫乘"而非"取消" |
| `done` | `finish_ly=1` | ✓ 已完成 | 无 | — | 主操作区显示"已结束,无需操作" |
两条纪律:①这张表只允许有**一份实现**(`utils/dutyState.js` 纯函数),卡片、列表、紧急卡片、吸底统计全部调用它;②`overdue` 必须用服务端时间基准(`serverTime` + 本地单调时钟补偿),**不得**直接用浏览器 `Date.now()` 判超时。
### 1.3 技术换算
UI 稿是原生 JS 单文件原型,落地要换三样东西,其余原样保留:
表 11-3 原型 → 工程的换算规则
| 原型做法 | 工程做法 | 保留不变 |
| --- | --- | --- |
| `:root` CSS 变量 + `body[data-mode]` 覆盖密度 | `styles/duty-tokens.scss` 同名变量,`<html data-mode>` | 变量名、两版数值(正文 18px / 按钮 56px / 3px 边) |
| `localStorage` 键 `duty.mode`、`duty.layout` | 同名同键,禁止改名(跨版本记忆) | 键名与取值 `senior/normal`、`card/list` |
| `TASKS` 数组 + `render()` 全量重绘 | Vue 组件 + 行级响应式更新(`state.changed` 只改单行) | 派生函数(`stats`/`isUrgent`/`noteOf`/`primaryOf`)逐条照搬 |
| `toast()` 4.5 s 自动消失 | `shared/Toast` 单例 | 4.5 s 与"最近操作"不覆盖业务记录 |
## 2. UI → 接口 → 代码落点
表 11-4 每个可见交互的接口与落点(**空白单元格 = 契约缺口,见 §4**)
| UI 交互 | 行号 | 接口 / 命令 | 权限点 | 幂等 | 后端落点 | 前端落点 |
| --- | --- | --- | --- | --- | --- | --- |
| 首屏列表 | `604~607` | `GET /duty/jcxx` | `duty:jcxx:list` | L0 | `DutyJcxxController` → `apt.duty.view` | `api/duty/jcxx.js#list` |
| 刷新列表 | `748` | 同上(复用,不新增接口) | 同上 | L0 | 同上 | `useDutyList#refresh` |
| 筛选/搜索 | `746~747`、`768` | 无(本地过滤 + `pageSize≤100`) | — | — | — | 同上(`computed`) |
| 停止叫乘 | `754` → `676~687` | `commands{STOP,reason}` | `duty:call:stop` | L2 | `apt.duty.state` | `useCommand` |
| 复叫 / 重新叫乘 | `753` | `commands{RE_CALL}` | `duty:call:recall` | L2 | 同上 | 同上 |
| 登记离寓 | `753` | `commands{MARK_LEAVE}` | `duty:call:markLeave` | L2 | 同上 | 同上 |
| 立即叫乘 | `632` | **缺口 `G-UI-01`** | `duty:call:*`? | L2 | — | — |
| 确认接通 | `634` | **缺口 `G-UI-02`** | — | — | — | — |
| 临时叫乘登记 | `697~721` | **缺口 `G-UI-03`** | `duty:jcxx:assignElement`? | L2 | — | — |
| 交接班 | `752` → `661~670` | `POST /duty/handover`(本期置灰) | `duty:handover:*` | L2 | `apt-duty` | `DutyHandoverDialog` |
| 吸底统计 | `592~603` | 缺口 `G-UI-04`(或本地派生) | — | — | — | `utils/dutyState.js#stats` |
| 紧急卡片 | `531~547` | 无(前端派生 `isUrgent`) | — | — | — | 同上 |
| 实时行更新 | 全页 | `GET /ws/duty`:`state.changed`/`alarm.raised`/`alarm.cleared`/`session.changed` | 会话即可 | — | `DutyWebSocketHandler` | `shared/ws/duty.js` |
| 界面版本 / 布局 | `728~745` | 无(纯前端) | — | — | — | `useUiPref` |
**一条硬约束**:UI 的"最近操作"只记业务动作(叫乘/离寓/停止/交接),**界面版本与布局切换不得写进去**(原型 `732`、`741` 已按此实现),否则值班员看不到自己刚才的业务动作。
## 3. 与 `07` 的差异
UI 稿是"去掉干扰项"的极简版(文件头 `13~14` 行自述:去掉时间线、录音、排序、分页、批量、快捷键、下拉筛选)。这与 `07` 表 7-2 的 D-2 全量范围**不等价**,必须先认账,否则 D-2 的验收项会凭空失效。
表 11-5 UI 稿与 D-2 范围的差异及处理
| 差异 | UI 稿 | `07` D-2 要求 | 处理 |
| --- | --- | --- | --- |
| 任务详情抽屉 D2 | 无(行内不展开) | `GET /duty/jcxx/{jcid}` + timeline | 接口照做(详情可从列表行数据 + 时间线抽屉直达),**UI 二期加"详情"文字按钮**,不进一期 |
| 语音临叫 / 喊话 / 多房间 / 挂断 | 无按钮 | 4 个接口 + `40908` 校验 | 二期;一期顶栏留"语音"占位不可见,`APT_VOICE_ENABLED=false` 时不渲染 |
| 要素指定 `ASSIGN_ELEMENT` / 时刻调整 `ADJUST_TIME` | 无入口 | D-2 必做 | 二期;一期的"临时叫乘"若按 `G-UI-03` 方案 A 落地,则等同 `ASSIGN_ELEMENT` 的一个子集 |
| 导出 Excel | 无 | `POST /duty/jcxx/export` | 二期,且**不做自动下载**(`04` 表 4-14) |
| 报警音 / TTS / 降级横幅 / 时钟偏差黄条 | UI 无(原型不发声) | D-2 必做 | **进一期**:无声音的报警台等于没有报警,`T-FE-08/09` 不后置 |
| 二次确认阈值(`ADJUST_TIME` ≤/>30 min) | 无 | `07` 表 7-3 有 2 条验收 | 随二期 `ADJUST_TIME` 一起做,一期不占验收项 |
| **新增**:双版本密度(适老/正常) | 有 | `07` 未提 | 进一期(老值班员是本页主要用户),新增 `T-FE-02`、`T-FE-12` |
| **新增**:卡片/列表双布局 | 有 | `07` 未提 | 进一期,新增 `T-FE-04` |
| **新增**:紧急卡片"最多 3 条、处理完自动消失" | 有 | `07` 只要求"行置红 + 报警音" | 进一期,纯前端派生 |
结论:**一期 = UI 稿 + 声音 + 实时 + 幂等**(`07` 表 7-3 中 D-1 全部 + D-2 的 6 条推送/降级项);**二期 = 抽屉/语音/要素/时刻/导出**(D-2 剩余项)。`07` 表 7-2 的批次划分不因此改动,只在批次内部拆"一期/二期"。
## 4. 缺口与待决策(开工前必须定)
表 11-6 UI 与契约的 7 个缺口
| # | 缺口 | 现象 | 为什么不能直接做 | 选项 | 默认建议 | 阻塞任务 |
| --- | --- | --- | --- | --- | --- | --- |
| `G-UI-01` | 立即叫乘(人工触发 `ryzt=0 → 1`) | UI 主按钮 `respond`,`04` 表 4-6 无此命令 | `RE_CALL` 前置是 `stoped=1` 或 `ryzt=2`,对 `ryzt=0` 直接 `40902`;用 `ADJUST_TIME` 把 `jcsj` 改成现在再等 Quartz,也要 30 s 以上 | A 新增 `CALL_NOW` 命令(新枚举 + 新权限点 `duty:call:now`);B 前端只"提示即将自动叫乘",删掉按钮;C 复用临叫接口(`04` 表 4-10 #8)但会 `ryzt` 语义分裂(`Q-17` 未决) | **B 先落地一期**(UI 按钮改为"等待自动叫乘,剩余 N 分钟"),A 单独立 `Q-20` 决策后再做 | `T-FE-07`、`T-BE-04` |
| `G-UI-02` | 确认接通(人工置 `talking`) | UI 主按钮 `confirm`,原型直接把行改 `talking` | 接通判据是 AMI `ConfbridgeJoin`(`05` 图 5-1),人工置 `talking` 会让状态机与真实会议桥脱节;且 `HANGUP` 回退逻辑依赖"是否已应答" | A 删除该按钮,`calling` 行主操作改为"挂断";B 保留但改调 `HANGUP`;C 新增"人工确认接通"命令(需同步改 `ConfbridgeJoin` 判据,风险高) | **A**:`calling` 主操作 = 挂断(`HANGUP`),接通由系统判定,值班员只看状态 | `T-FE-05`、`T-FE-07` |
| `G-UI-03` | 临时叫乘"登记一条新任务" | UI 输房间/时刻/车次后 `TASKS.unshift()`,并"立即叫乘" | `40909` 明确"**不存在为某人建 jcxx 行的接口**"(`docs/设计变更/02` R-26);新行还需要 `ryid`/`jlid`,否则永远 `UNMATCHED` | A 只对**已入寓且有行**的人补要素(= `ASSIGN_ELEMENT` 子集:房间→`jcid`→`kcsj`/`cc`,`jcsj` 由服务端按规则算);B 新增临时加开建单接口(`apt_local_task.origin=LOCAL_TEMP_EXTRA`),需改 CR-01 结论与错误码;C 本期移除该按钮 | **A**,且服务端**不信任**前端算的"开车=叫乘+30 分钟"(`04` §3.3:派生值只允许服务端算) | `T-FE-10`、`T-BE-04` |
| `G-UI-04` | 吸底统计与紧急卡片计数 | UI 由全量 `TASKS` 派生 | 列表 `pageSize` 上限 100 且默认"当日+未完成",分页后前端算不全;跨时间窗会漏 | A 由后端给 `GET /duty/jcxx/counts`(`todo/call/alarm/need/done/urgent` 六个整数);B 前端只统计当前页并在数字后标"(本页)" | **A**(约 0.5 人日,避免"报警 3 条"实际 13 条) | `T-BE-05`、`T-FE-05` |
| `G-UI-05` | "停止叫乘"按钮显隐过宽 | UI 在 `calling/talking/alarm/leave` 都显示停止(`468`) | `STOP` 只允许 `ryzt in (1,2)`;`talking/leave` 点了必 `40902`,值班员会以为系统坏了 | A 按 `ryzt` 收缩:仅 `ryzt∈{1,2}` 显示 `STOP`;`ryzt∈{3,4,5}` 显示"登记离寓";B 保留按钮并靠错误提示兜底 | **A**,并在 `utils/dutyState.js` 用同一张表驱动显隐 | `T-FE-05` |
| `G-UI-06` | 交接班按钮本期置灰 | UI 是可用主流程(含汇总未处理数) | `07` 表 7-2:D7 本期置灰,`APT_DUTY_HANDOVER_ENABLED=false` | A 按开关置灰 + `title`"本期未开放" + 点击只 Toast 不发请求;B 直接隐藏 | **A**(保留入口便于培训与二期开闸) | `T-FE-11`、`T-BE-08` |
| `G-DOC-01` | UI 目录无说明文件 | `docs/应用端/README` 与 `07` 均引用 `docs/公寓端/ui/README` 表 1.1(D1~D10 出稿状态),该文件**不存在** | 实施方按图施工时找不到"哪些页有稿" | A 补一页 `ui/README.md`(10 行:D1~D10 出稿状态 + 本文链接);B 改引用指向本文 §1 | **A**(10 分钟,避免二次误引) | 文档任务 |
**缺口处理的纪律**:`G-UI-01`/`G-UI-02`/`G-UI-03` 未定前,对应 UI 按钮**要么删、要么置灰**,不允许"先做成可点、后端日后补"。理由是这三处一旦先上线,值班员会形成"点了就该叫"的预期,而实际情况是没叫或叫了两次。
## 5. 开发任务拆解
人日为"1 名熟练开发的有效工时",不含评审与联调返工。
表 11-7 前端任务(`RuoYi-Vue2` 工程,`src/entries/duty/**`)
| # | 任务 | 主要文件 | 依赖 | 人日 |
| --- | --- | --- | --- | --- |
| `T-FE-01` | 入口与路由骨架:`/duty/jcxx` 页面、`sys_menu` 兜底、`/duty/:pathMatch(.*)*` 重定向 | `entries/duty/router/index.js`、`views/jcxx/DutyConsole.vue` | `T-BE-01` | 2 |
| `T-FE-02` | 设计令牌与两版密度:`scss` 变量、`data-mode`、`localStorage duty.mode` | `styles/duty-tokens.scss`、`useUiPref.js` | — | 1.5 |
| `T-FE-03` | 顶栏:系统状态徽标(接 `GET /duty/health` 的 `callReady`/`degraded`)、时钟+班次、值班员、三按钮 | `components/DutyHeader.vue` | `T-FE-01` | 1.5 |
| `T-FE-04` | 任务卡片 + 列表双布局 + 布局记忆(`duty.layout`),切换保持筛选/搜索/滚动位置 | `DutyTaskCard.vue`、`DutyTaskRow.vue` | `T-FE-02` | 2.5 |
| `T-FE-05` | 状态派生纯函数与单测:`deriveState/primaryAction/visibleActions/noteOf/isUrgent/stats`(按表 11-2 + `G-UI-05`) | `utils/dutyState.js`、`tests/dutyState.spec.js` | — | 2 |
| `T-FE-06` | 筛选段(含计数)+ 搜索 + 空态 + 紧急卡片(≤3 条、自动消失、空态文案) | `DutyToolbar.vue`、`DutyAlerts.vue` | `T-FE-05` | 1.5 |
| `T-FE-07` | 命令通道:`commandId`(uuid,重试复用)、`expectedState`、`duplicated` 处理、`409xx` 人话文案(`40902`→"该行已被他人处理,请刷新") | `api/duty/jcxx.js`、`useCommand.js` | `T-BE-04` | 2 |
| `T-FE-08` | WS 客户端:握手、`ping/pong` 30 s、指数退避 1→30 s、`seq` 乱序丢弃、重连后拉一次列表快照、断流 60 s 兜底轮询 | `shared/ws/duty.js`、`useDutySocket.js` | `T-BE-06` | 2.5 |
| `T-FE-09` | 告警音单例 + TTS 单队列 + 时钟偏差黄条(>5 s) | `shared/audio/AlarmVoiceController.js` | `T-FE-08` | 2 |
| `T-FE-10` | 三个弹窗:停止(原因必选,"其他"≥2 字)、交接班(汇总数,置灰)、临时叫乘(`G-UI-03` 方案 A 字段);默认焦点在"取消"、`Esc` 关闭不提交 | `components/dialogs/*` | `T-FE-07` | 2 |
| `T-FE-11` | 降级与置灰:`callReady=false`、`APT_VOICE_ENABLED`、`APT_DUTY_HANDOVER_ENABLED`、`outbox.backlog` 横幅 | `useFeatureFlags.js` | `T-FE-03` | 1 |
| `T-FE-12` | 适老与无障碍走查:点击区 ≥56px、焦点环 4px、对比度 AA、1024×768 无横向滚动、纯键盘可完成"停止叫乘" | 全局 | `T-FE-04`/`06`/`10` | 1.5 |
| `T-FE-13` | 值班员登录页 `/duty/login`:账号/密码/验证码/记住账号、字段级校验、失败文案映射与验证码刷新(详见 `12` 表 12-2/12-3/12-5) | `views/login/DutyLogin.vue`、`api/duty/login.js` | `T-BE-09` | 1.5 |
| `T-FE-14` | 准入守卫与失效重定向:`guard.js`、`safeRedirect()`、401 单飞跳登录、退出清 WS 与告警音(详见 `12` §5/§6/§7) | `entries/duty/router/guard.js`、`shared/request/duty401.js` | `T-FE-13` | 1 |
| | **合计** | | | **24.5** |
表 11-8 后端任务(`apt-duty` 模块,包结构见 `02` 表 2-2)
| # | 任务 | 主要类 / 文件 | 依赖 | 人日 |
| --- | --- | --- | --- | --- |
| `T-BE-01` | 模块骨架 + `duty:*` 权限点 SQL + `@DutyLog` 切面 + 6 条 ArchUnit 断言 | `apt-duty/pom.xml`、`sys_menu` 初始化、`DutyOperationAspect` | — | 2 |
| `T-BE-02` | `GET /duty/jcxx`:默认"当日+未完成"、`pageSize≤100`、排序(紧急→未完成→`jcsj` 升序)、`jcsj/kcsj` 与 `alarmFlag` 出参 | `DutyJcxxController`、`DutyJcxxQueryService`、`DutyJcxxMapper.xml` | `T-BE-01` | 2 |
| `T-BE-03` | `GET /duty/jcxx/{jcid}` + `.../timeline`(供二期抽屉,一期不阻塞) | 同上 | `T-BE-02` | 1.5 |
| `T-BE-04` | `POST /duty/jcxx/{jcid}/commands`:8 命令门面、`commandId` 幂等、`expectedState` 乐观校验、`ryztBefore/After` | `DutyCommandService`、`ManualActionRepository` | `T-BE-01`、`apt.duty.state` | 2.5 |
| `T-BE-05` | `GET /duty/jcxx/counts`(`G-UI-04` 方案 A) | `DutyJcxxQueryService#counts` | `T-BE-02` | 0.5 |
| `T-BE-06` | `/ws/duty`:握手校验 `gyid`、`seq` 单调、`state.changed`/`alarm.*`/`session.changed` 三类必需消息、心跳 30 s | `DutyWebSocketHandler`、`DutyPushService` | `T-BE-04` | 2.5 |
| `T-BE-07` | 报警事件:`alarmEventKey` 生成、`alarm.raised`/`alarm.cleared` 成对、停叫后不再重发 | `DutyPushService` + `apt.dispatch` 事件订阅 | `T-BE-06` | 1 |
| `T-BE-08` | `POST/GET /duty/handover` 占位:开关关闭返回 `50102`,开关打开才写 `duty_shift_handover` | `DutyHandoverController` | `T-BE-01` | 0.5 |
| `T-BE-09` | `/duty/**` 准入拦截器:权限点 `duty:jcxx:list` + `gyid` 绑定校验,失败 `40301`/`40302`(详见 `12` §1) | `apt-duty/web/DutyAccessInterceptor` | `T-BE-01` | 0.5 |
| | **合计** | | | **13** |
## 6. 迭代排期
五轮,每轮结束必须**能演示**(不是"代码提交了")。总工作量 ≈ 37.5 人日 ≈ 3 人 × 13 个工作日(含 1 天缓冲)。
表 11-9 五轮迭代与退出条件
| 轮 | 主题 | 任务 | 演示脚本(评审当场跑) | 退出条件 | 人日 |
| --- | --- | --- | --- | --- | --- |
| R1 | 骨架 + 只读 | `T-BE-01~03`、`T-BE-09`、`T-FE-01~04`、`T-FE-06`、`T-FE-13~14` | 未登录直达 `/duty/jcxx` 被重定向到 `/duty/login`;登录后回跳原深链;用真实 `gy_jcxx` 数据打开列表,切两版密度与卡片/列表,搜 305 命中 | 无 404/白屏;1024×768 无横向滚动;字号与点击区达标;`12` 表 12-11 第 1~5、9~11 项通过 | 14 |
| R2 | 状态 + 命令 | `T-BE-04`、`T-FE-05`、`T-FE-07`、`T-FE-10`(停止/交接班) | 对一条 `ryzt=1` 发停止(选原因)→ 行变"已停止";同 `commandId` 连点 3 次只有 1 条 `duty_manual_action` | 4 条命令可达且留痕;非法态给 `40902` 人话;`G-UI-01/02/05` 已按建议处置 | 9 |
| R3 | 实时 + 告警 | `T-BE-06~07`、`T-FE-08~09` | 拔网线 30 s → 5 s 内重连、页面不刷新;制造一条 `NO_ANSWER` → 行置红 + 报警音响 + TTS | `seq` 倒退被丢弃;断流 90 s 由兜底轮询补齐;停叫后 10 min 不重响 | 8 |
| R4 | 弹窗补齐 + 降级 | `T-BE-05`、`T-BE-08`、`T-FE-11`、`T-FE-10`(临时叫乘,按 `G-UI-03` A) | 停 Asterisk → 顶栏状态变"降级"且语音类置灰;交接班按钮置灰且无写请求;临时叫乘补要素成功 | 降级不静默、不禁用整页;置灰按钮点击无网络请求 | 4 |
| R5 | 无障碍 + 演练 | `T-FE-12` + 全量验收 | 老值班员盲操作 5 条任务;断网 2 h 演练一次 | 表 11-10 全部通过;操作步骤增幅 ≤10% | 2.5 |
**为什么 R2 在 R3 之前**:命令通道是"能被信任"的前提,实时只是"更快看见"。顺序反过来会出现"推送很顺但点了没反应"的最差体验,且 R3 的验收(停叫后不重响)本身依赖 R2 的 `commandId` 与 `duty_manual_action`。
## 7. 验收清单
表 11-10 一期 16 条可复现验收(每条都要能在评审现场跑出来)
| # | 验收项 | 通过标准 |
| --- | --- | --- |
| 1 | 首屏 | 骨架 ≤1 s、数据 ≤2 s;空数据显示"这里暂时没有任务"且可一键查看全部 |
| 2 | 超时提示 | `ryzt=0` 且过点:行红色 + "已过叫乘时间 N 分钟",`N` 与 `serverTime` 一致(浏览器时钟改慢 10 min 不影响) |
| 3 | 时钟偏差 | 偏差 >5 s 时顶部黄条,且**不阻塞**任何操作 |
| 4 | 状态映射 | 8 个 UI 状态与表 11-2 逐行一致(造 8 条数据一次看全) |
| 5 | 按钮显隐 | 仅 `ryzt∈{1,2}` 出现"停止叫乘";`ryzt∈{3,4,5}` 出现"登记离寓"(`G-UI-05`) |
| 6 | 命令幂等 | 同 `commandId` 连发 3 次 → `duty_manual_action` 1 行,第 2/3 次 `duplicated=true` |
| 7 | 乐观校验 | `expectedState` 不符 → `40902` 且提示"该行已被他人处理,请刷新",行状态不本地跳变 |
| 8 | 停止原因 | 未选原因不能提交;选"其他原因"且 <2 字给出字段级错误 |
| 9 | 弹窗焦点 | 打开即在"取消";`Esc` 关闭且不发请求;回车不误确认 |
| 10 | 布局切换 | 卡片/列表互切后筛选、搜索词、滚动位置、数据完全不变;刷新页面仍记住 |
| 11 | 断线重连 | 断网 30 s:5 s 内重连、页面不刷新、`commandId` 不重置、未提交的弹窗内容不丢 |
| 12 | 推送可靠性 | 构造 `seq` 倒退被丢弃;断流 90 s 出现"数据可能滞后"并可手动刷新 |
| 13 | 报警音 | 3 行同时报警只播 1 条(单例);逐行 dismiss 各自停;停叫后列表回包不触发重响 |
| 14 | 降级 | 停 Asterisk:顶栏"降级"、语音入口置灰 + `title` 说明;`STOP`/`MARK_LEAVE` 仍可用 |
| 15 | 交接班置灰 | 开关关闭时按钮置灰、点击仅提示"本期未开放",网络面板**无** `/duty/handover` 请求 |
| 16 | 适老与无障碍 | 适老版正文 ≥18px、点击区 ≥56px、对比度 AA;纯键盘可完成"搜索 305 → 停止叫乘 → 选原因 → 确认" |
## 8. 风险
表 11-11 五项风险与缓解
| # | 风险 | 触发信号 | 缓解 |
| --- | --- | --- | --- |
| 1 | `G-UI-01/02/03` 未定就开工 | 前端按原型做出"可点但无效"的按钮 | 三个按钮先删/置灰(§4 纪律);`O-UI` 决策单 3 项挂 R1 评审 |
| 2 | 双版本密度维护成本翻倍 | 两版样式各自打补丁,出现"某按钮只在一版对齐" | 只允许覆盖**变量**(`--fs-*`/`--tap`/`--bd`),禁止写版本专属选择器;R1 后冻结变量表 |
| 3 | 派生口径前后端各写一套 | 同一行在卡片显示"待叫乘"、超时区显示"报警" | 表 11-2 为唯一口径,前端 `dutyState.js` 有单测;后端出参补 `derivedState` 便于对账 |
| 4 | 报警音被浏览器自动播放策略拦截 | 首次进入无声音,值班员以为没报警 | 首次交互解锁(`T-FE-09`);未解锁时顶栏显示"音频未就绪"并可点击重试 |
| 5 | 旧页 `JcXx.vue` 与新页并存期间口径漂移 | 同一任务两页状态不一致 | 新页上线期间旧页只读保留(`07` §8 切换顺序);差异以 `gy_jcxx` 为准,不做双写 |
## 9. 交付物与提交纪律
表 11-12 每轮交付物
| 轮 | 代码 | 文档 | 验证记录 |
| --- | --- | --- | --- |
| R1 | `entries/duty` 页面骨架、`DutyLogin.vue`、`router/guard.js`、`duty-tokens.scss`、卡片/列表组件 | 两版密度变量表、登录准入四闸门说明(`12` 表 12-1) | 1024/1366/1920 三张截图 + 未登录重定向截图 |
| R2 | `useCommand`、`dutyState.js` + 单测、停止/交接班弹窗 | 表 11-2 落地说明、`G-UI-05` 收缩依据 | 表 11-10 第 4~9 项输出 |
| R3 | `shared/ws/duty.js`、`AlarmVoiceController`、`/ws/duty` 后端 | WS 消息清单与 `seq` 规则 | 断网录像 + `seq` 乱序用例日志 |
| R4 | 计数接口、临时叫乘、降级开关 | 缺口决策回填(`G-UI-01~04` 结论) | 停 Asterisk 演练记录 |
| R5 | 无障碍修正 | 走查问题清单与修复对照 | 老值班员盲操作录像 + 表 11-10 全项 |
**提交纪律**:本轮只产出文档;实施阶段每轮一个 PR,PR 描述必须引用本文件表号(例:`表 11-6 G-UI-05`),便于回溯"这个按钮为什么只有两种状态"。不执行 `git commit` / `git push`。
@@ -0,0 +1,279 @@
# 12 值班员登录与准入鉴权
> **目标**:值班员必须先通过 `/duty/login` 完成身份验证,才能进入 `D1 叫乘盯控台`;未登录、会话失效、权限不足三种情况一律不渲染盯控台,并自动回到登录页。
> 追溯:`docs/应用端/02` §6 表 2-8(`/duty/**` 拦截器链)、表 2-9(`duty:*` 权限点);`04` 表 4-4(错误码);`06` 表 6-2(Nginx 两条 location);`11` 表 11-7/11-8(前端/后端任务)。
> 现状依据:`RuoYi-Vue-master` 的 `SysLoginService`/`UserDetailsServiceImpl`/`SysPasswordService`、`i18n/messages.properties`;`RuoYi-Vue2-master` 的 `src/permission.js`、`src/utils/request.js`、`src/views/login.vue`。
> **本文只做设计,不改 RuoYi 认证实现**(`docs/公寓端/08` 差-3:不魔改框架);不新增后端认证接口,不新增错误码。
## 0. 一条需要登记的修订
`02` §6 与 `README` 表 0-3 结论 8 写的是"**不做独立登录页**(复用 `/login`)"。本次要求"值班员登录页面",与之冲突,处理方式拆成两句:
- **页面**:新增独立页 `/duty/login`(`entry-duty` 内),理由是值班台要大字大按钮、要显示公寓名与班次、且不能让值班员落到管理端 `/index` 的业务首页。
- **认证**:**仍然复用** RuoYi 的 `/login`、`/captchaImage`、`/getInfo`、`/getRouters`、`/logout`,**不做独立 token 签发、不新建认证服务、不改 `TokenService`**。所以 §6 的另外两条"不做"继续成立。
本次修订登记为 `差-D5`(接在 `08` 表 8-4 的 `差-D1~差-D4` 之后),并回改 `02` §6 第一条表述;`06` 的 Nginx 配置**不需要改**(`/duty` 与 `/apt` 已指向同一 SPA,`try_files … /index.html` 天然覆盖 `/duty/login`)。
## 1. 准入的四个闸门
登录成功不等于能进盯控台。四个闸门全部通过才渲染,任一失败都回到登录页并给出具体原因。
表 12-1 准入闸门(顺序固定,不可合并)
| # | 闸门 | 判定 | 失败表现 | 谁负责 |
| --- | --- | --- | --- | --- |
| 1 | 会话 | `getToken()` 存在且 `/getInfo` 返回 200 | 跳 `/duty/login?redirect=…&reason=expired` | 前端守卫 + 后端 `JwtAuthenticationTokenFilter` |
| 2 | 权限 | `permissions` 含 `duty:jcxx:list`(或角色含 `duty_operator`) | 登录页提示"当前账号无值班台权限,请联系管理员开通 `duty:*`";后端 `40301` | 前端守卫 + `@PreAuthorize` |
| 3 | 数据域 | `gyid` 非空且等于 `sys_user` 绑定的公寓 | 提示"账号未绑定公寓或绑定不一致";后端 `40302` | 后端 `AptContextInterceptor` + 新增 `DutyAccessInterceptor` |
| 4 | 路由 | `getRouters` 已加载且包含 `/duty/jcxx` | 提示"菜单未授权",不白屏 | 前端守卫 |
**为什么第 2 闸门要用 `duty:jcxx:list` 而不是角色名**:`sys_menu.perms` 是权限点真源(`02` §5 差-7),角色是授权容器;用权限点判定可与 `@PreAuthorize` 同码,避免"前端放行、后端拒绝"。
后端新增一个轻量拦截器就够,**不写业务分支**:
```java
// apt-duty/web/DutyAccessInterceptor.java(新增,约 40 行)
// 只做三件事:会话是否有效(框架已做,此处兜底)→ 是否具备 duty:* 任一查询权限 → gyid 是否绑定
// 不通过时抛 AptException(40301 / 40302),不返回页面、不写审计
```
## 2. 登录页面
### 2.1 输入项
表 12-2 `/duty/login` 的字段与默认值
| # | 字段 | 控件 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- | --- | --- |
| 1 | 账号 `username` | 文本框,`autocomplete="username"` | 是 | **空** | 值班员工号或值班员账号;**禁止预填**(现状 `login.vue:78~79` 预填 `admin/admin123`,值班台不得沿用) |
| 2 | 密码 `password` | 密码框 + 显示/隐藏切换,`autocomplete="current-password"` | 是 | 空 | 不允许粘贴被禁用(值班员需要从密码条粘贴) |
| 3 | 验证码 `code` | 4 位文本框 + 图片,点击图片刷新 | 是 | 空 | 受 `captchaEnabled` 开关控制,**生产必须开启** |
| 4 | 记住账号 `rememberMe` | 复选框 | 否 | 上次值 | **只记账号**(30 天),不记密码;现状 `login.vue:137~145` 把密码加密后写 Cookie,本页**不采用** |
| 5 | 公寓与班次 | 只读文本 | — | 登录前隐藏 | 登录成功后由 `/getInfo` 返回的公寓名 + 当前班次填充 |
| 6 | 登录按钮 | 主按钮(56px,适老版 64px) | — | — | 提交中 `loading` 且禁用,防重复提交 |
页面另外两条:①**不提供"注册"入口**(`register=false`),值班员账号由公寓管理员在管理端创建;②**提供"忘记密码请联系管理员"** 的一行静态说明,不做自助重置(`sys_user` 改密归管理端)。
### 2.2 校验规则
前端即时校验只为了少一次往返,**后端仍是权威**(规则来自 `messages.properties` 的 `user.username.not.valid` / `user.password.not.valid`)。
表 12-3 校验规则
| 字段 | 前端规则 | 触发时机 | 提示文案 | 后端权威规则 |
| --- | --- | --- | --- | --- |
| 账号 | 必填;`trim` 后 2–20 字符;汉字/字母/数字/下划线;**非数字开头** | `blur` | "账号为 2–20 位字母、数字或下划线,且不能以数字开头" | `user.username.not.valid` |
| 密码 | 必填;5–50 字符;不含空格 | `blur` | "密码为 5–50 个字符" | `user.password.not.valid` |
| 验证码 | 必填;恰好 4 位;字母大小写不敏感 | `change` | "请输入 4 位验证码" | Redis 比对,`Constants.CAPTCHA_EXPIRATION = 2` 分钟,**一次一用** |
| 提交 | 三字段全部通过才发请求;请求中禁止重复点击;连续失败后按钮进入 3 s 冷却 | `click` | — | — |
三条实现要求:①`trim` 只作用于账号,密码**不做任何 trim 或大小写处理**;②校验失败时焦点落到第一个错误字段(键盘可达);③错误提示显示在字段下方,**不用全局 Toast 代替字段级错误**。
## 3. 登录接口与反馈
### 3.1 请求与响应
表 12-4 登录相关接口(全部为既有接口,无新增)
| 步 | 接口 | 入参 | 出参 | 说明 |
| --- | --- | --- | --- | --- |
| 1 | `GET /captchaImage` | — | `captchaEnabled`、`uuid`、`img`(base64) | 进页面即拉;刷新图片只换 `uuid` + 图 |
| 2 | `POST /login` | `username`、`password`、`code`、`uuid` | `token` | 成功同时写 `sys_logininfor`(框架内建) |
| 3 | `GET /getInfo` | — | `user`、`roles`、`permissions` | 取 `gyid`、公寓名、班次 |
| 4 | `GET /getRouters` | — | 菜单树 | 生成 `/duty/**` 动态路由 |
| 5 | `POST /logout` | — | — | 见 §7 |
`/login` 失败时框架返回 `code=500` + 中文 `msg`(不是 `401xx`)。**不魔改控制器**:前端按 `msg` 关键词映射成值班台文案;若后续要结构化,只在 `GlobalExceptionHandler` 增加映射,不改 `SysLoginService`。
### 3.2 失败反馈
表 12-5 失败场景 → 文案 → 页面动作(文案与 `messages.properties` 一一对应)
| # | 后端 `msg`(原文) | 触发条件 | 值班台文案 | 页面动作 |
| --- | --- | --- | --- | --- |
| 1 | `验证码错误` | 验证码不匹配 | "验证码不正确,已刷新,请重新输入" | 清空验证码 + 刷新图片 + 焦点回验证码框;**保留账号与密码** |
| 2 | `验证码已失效` | 超过 2 分钟 | "验证码已过期,已刷新" | 同上 |
| 3 | `用户不存在/密码错误` | 账号不存在或密码错 | "账号或密码错误(今日还可尝试 N 次)" | 清空密码 + 焦点回密码框 + 刷新验证码;**保留账号**;`N` 由前端按本地失败次数推算,仅作提示 |
| 4 | `密码输入错误5次,帐户锁定10分钟` | 触发 `maxRetryCount=5` | "密码连续错误 5 次,账号已锁定 10 分钟,请 10 分钟后再试或联系管理员" | 禁用登录按钮并显示倒计时;刷新验证码 |
| 5 | `用户已封禁,请联系管理员` | `status='1'` | "该账号已停用,请联系公寓管理员" | 禁用登录按钮(需改配置才能恢复) |
| 6 | `对不起,您的账号已被删除` | `del_flag='2'` | "该账号不存在,请联系公寓管理员" | 同上 |
| 7 | `很遗憾,访问IP已被列入系统黑名单` | IP 黑名单 | "当前设备网络被限制访问,请联系管理员" | 禁用登录按钮 |
| 8 | `角色已封禁,请联系管理员` | `sys_role.status='1'` | "账号角色已停用,请联系公寓管理员" | 同上 |
| 9 | 网络错误 / 超时 / `50301` | 网关或服务不可用 | "系统暂时不可用,请稍后重试" | 保留全部输入,可重试;**不清验证码** |
| 10 | 登录成功但无 `duty:jcxx:list` | 闸门 2 失败 | "登录成功,但当前账号没有值班台权限,请联系管理员开通 `duty:*`" | 调 `/logout` 清 token,**留在登录页**(不跳 `/duty`) |
| 11 | 登录成功但 `gyid` 为空 | 闸门 3 失败 | "账号未绑定公寓,请联系管理员" | 同上 |
三条纪律:①**密码错误与账号不存在必须给同一句文案**(防账号枚举,框架原文已是"用户不存在/密码错误",前端不得拆开);②**任何失败都要刷新验证码**(除第 9 条网络错误),否则同一张图可被反复试;③**失败不写"最近操作"**(那是业务动作区),只写 `sys_logininfor`。
### 3.3 成功反馈
- 按钮变 `loading` → 成功后 Toast "登录成功"(1.5 s,不阻塞)。
- 依次完成 `getInfo` → `getRouters` → 闸门 2/3 判定,**全部通过才跳转**。
- 跳转用 `router.replace()`,使浏览器"后退"不能回到登录页(避免误以为已退出)。
## 4. 跳转逻辑
表 12-6 目标页解析规则(`redirect` 必须过白名单)
| `redirect` 取值 | 判定 | 目标 |
| --- | --- | --- |
| 空 / `undefined` | — | `/duty/jcxx` |
| 以 `/duty/` 开头且不含 `//`、`://`、`/duty/login` | 合法 | 原值(例如 `/duty/jcxx?jcid=JC…`) |
| `/apt/**`、`/index`、`/platform/**` 等其它前缀 | 越界 | `/duty/jcxx` |
| 以 `//`、`http://`、`https://` 开头 | 开放重定向攻击 | `/duty/jcxx`,并记一条前端 warn |
| `/duty/login` | 自指,会死循环 | `/duty/jcxx` |
**现状缺陷必改**:`login.vue:147` 直接 `this.$router.push({ path: this.redirect })`,未做任何校验,`?redirect=//evil.com` 可被打成外部跳转;值班台必须用表 12-6 的白名单函数 `safeRedirect(raw)`。
跳转前还要满足两个条件,否则会出现"进入 `/duty/jcxx` 却 404 或被踢回登录":①`getRouters` 已 `addRoutes` 完成;②`gyid` 已写入 store。守卫的写法见 §6。
## 5. 会话维持与失效
表 12-7 会话参数(沿用后端配置,不新增)
| 项 | 值 | 来源 |
| --- | --- | --- |
| token 有效期 | 30 分钟 | `application.yml` `token.expireTime: 30` |
| 自动续期窗口 | 剩余 < 20 分钟时,任何 `/duty/**` 请求触发续期(框架 `TokenService` 行为) | 同上 |
| 验证码有效期 | 2 分钟,一次一用 | `Constants.CAPTCHA_EXPIRATION` |
| 密码错误锁定 | 5 次 / 锁 10 分钟 | `user.password.maxRetryCount`、`lockTime` |
| 存储位置 | Cookie(`Admin-Token`),与公寓端同键 | `utils/auth.js` |
表 12-8 失效场景与统一处置
| 场景 | 触发 | 处置 | 是否保留页面 |
| --- | --- | --- | --- |
| 会话过期 | 任一接口 `401` | 清 store + 关 WS + **直接** `replace('/duty/login?redirect=<current>&reason=expired')` | 否 |
| 被强制退出 | `/getInfo` 返回 `user.forcelogout` | 同上,`reason=forceLogout`,文案"管理员已将您强制退出,请重新登录" | 否 |
| 账号被停用/删除 | `40301` 且 `msg` 含"封禁"/"删除" | 清 token,`reason=blocked` | 否 |
| 密码被管理员重置 | 后端拒绝旧 token(框架 `sys_user.pwd_update_date` 校验) | 同"会话过期",`reason=pwdChanged` | 否 |
| 网络中断 | 请求超时(非 401) | **不跳登录页**,顶部显示"网络中断,正在重试" | 是 |
| 多标签页 | A 页退出 | 其他标签下次请求 `401` 后同样跳登录,`redirect` 各自记住 | 否 |
**现状缺陷必改**(三条,直接违反本次需求):
1. `request.js:88` 弹"继续留在该页面?"确认框,选"取消"就留在过期页 —— 改为**不给取消选项**,`401` 无条件跳登录。
2. `request.js:91` 跳的是 `location.href = '/index'`(管理端首页),应为 `/duty/login` 且带 `redirect`。
3. 12 个并发请求同时 `401` 会弹 12 次 —— 用 `isRelogin` 单飞标志 + 一个待跳转队列,由守卫统一执行一次跳转。
## 6. 路由守卫
```js
// src/entries/duty/router/guard.js(新增)
const WHITE = ['/duty/login']
router.beforeEach(async (to, from, next) => {
if (WHITE.includes(to.path)) {
return getToken() ? next('/duty/jcxx') : next() // 已登录访问登录页 → 直接进盯控台
}
if (!getToken()) {
return next(`/duty/login?redirect=${encodeURIComponent(to.fullPath)}`)
}
if (store.getters.roles.length === 0) {
try {
await store.dispatch('GetInfo') // 闸门 1 + 3
if (!store.getters.permissions.includes('duty:jcxx:list')) {
await store.dispatch('LogOut') // 闸门 2
return next('/duty/login?reason=noPermission')
}
const routes = await store.dispatch('GenerateRoutes')
router.addRoutes(routes)
return next({ ...to, replace: true }) // 确保 addRoutes 完成再进
} catch (e) {
return next(`/duty/login?reason=expired&redirect=${encodeURIComponent(to.fullPath)}`)
}
}
next()
})
```
图 12-1 登录到进入盯控台的完整链路
```mermaid
sequenceDiagram
autonumber
participant U as 值班员
participant L as /duty/login
participant API as /login /getInfo /getRouters
participant G as 守卫 guard.js
participant I as DutyAccessInterceptor
participant D as D1 盯控台
U->>L: 账号 + 密码 + 验证码
L->>API: POST /login
alt 失败
API-->>L: code=500 + msg
L-->>U: 字段级提示 + 刷新验证码
else 成功
API-->>L: token
L->>API: GET /getInfo(闸门 1/3)
L->>API: GET /getRouters
L->>L: 闸门 2 权限点校验
L->>G: replace(safeRedirect)
G->>I: GET /duty/jcxx
I-->>D: 放行(或 40301/40302)
end
```
## 7. 退出、切换用户与交接班
表 12-9 三种"离开"的区别
| 动作 | 入口 | 触发 | 必须清理 | 不清理 |
| --- | --- | --- | --- | --- |
| 退出登录 | 顶栏"退出"(新增,放在值班员名字下拉里) | 二次确认 | token、store(roles/permissions/user)、WS 连接、告警音单例、`alarmEventKey` 集合、页面级筛选 | `duty.mode`、`duty.layout`、`rememberMe` 账号 |
| 切换用户 | 退出后再登录 | 同上 | 同上 + 上一位值班员的"最近操作"文案 | 同上 |
| 交接班 | 顶栏"交接班"(本期置灰,`G-UI-06`) | 二次确认 | **不登出、不换 token**,只写 `duty_shift_handover` | 全部保持不变 |
**为什么交接班不等于退出**:同一台工作站、同一个人继续值班是常态;交接班是业务动作(把未处理事项交给下一班),不是身份变化。若当成退出,会让 `duty_operation_log` 的操作人断裂。
**切换用户必须清 WS 与告警音**:否则上一位值班员的报警音会在新用户会话里继续响,且 `alarm.cleared` 对应不上(`05` 表 5-5 的 `alarmEventKey` 是会话级状态)。
## 8. 安全约束
表 12-10 九条硬约束
| # | 约束 | 理由 | 落地 |
| --- | --- | --- | --- |
| 1 | 不预填演示账号 | `login.vue` 现状 `admin/admin123`,值班台直接暴露默认口令 | 删掉默认值,评审时检查 DOM |
| 2 | 不存明文或可逆密码 | 现状 `rememberMe` 用 `encrypt()` 写 Cookie 30 天,前端可逆 | 只记账号;密码框 `autocomplete` 交给浏览器密码管理器 |
| 3 | 生产必须开验证码 | 共享工作站,无验证码等于可暴力猜 | `sys_config` 的 `sys.account.captchaEnabled=true`,列入 `06` 部署检查项 |
| 4 | 失败刷新验证码 | 一张图反复试 | 表 12-5 第 1~5 条 |
| 5 | 凭据不进 URL | `redirect` 只带路径,不带 `token`/`password` | 表 12-6 白名单 |
| 6 | 防开放重定向 | `?redirect=//evil.com` | `safeRedirect()` 单测覆盖 6 种取值 |
| 7 | 日志脱敏 | 失败日志会带 `username`,**不得**带密码或验证码 | 后端已有;前端 `console` 不得打印 `loginForm` |
| 8 | 强制 HTTPS | token 走 Cookie | Nginx 301 + `Secure`;内网试点至少禁用明文外网 |
| 9 | 无人值守自动锁屏 | 值班台 24 h 开机,人离开后无保护 | 复用现成 `/lock`(`store/modules/lock.js`,15 分钟无操作触发),解锁需重输密码 |
## 9. 验收清单
表 12-11 十三条可复现验收
| # | 验收项 | 通过标准 |
| --- | --- | --- |
| 1 | 未登录直达 | 浏览器直接开 `/duty/jcxx?jcid=JC1` → 落到 `/duty/login?redirect=%2Fduty%2Fjcxx%3Fjcid%3DJC1`,**不出现**盯控台骨架 |
| 2 | 登录成功回跳 | 输入正确后回到原 `jcid` 深链页,且浏览器后退不回登录页 |
| 3 | 默认跳转 | 直接开 `/duty/login` 登录成功 → `/duty/jcxx` |
| 4 | 开放重定向 | `?redirect=//evil.com`、`?redirect=/apt/room`、`?redirect=/duty/login` 三种 → 均落 `/duty/jcxx` |
| 5 | 字段校验 | 账号 1 位、账号数字开头、密码 4 位、验证码 3 位 → 各自字段级提示,且**不发请求**(网络面板为空) |
| 6 | 密码错误 | 提示"账号或密码错误",账号保留、密码清空、验证码刷新;连续 5 次后提示锁定 10 分钟且按钮禁用 |
| 7 | 验证码错误/过期 | 提示对应文案并自动刷新图片,密码不清空 |
| 8 | 账号停用 | 提示"该账号已停用,请联系公寓管理员",不跳转 |
| 9 | 无值班台权限 | 登录成功但无 `duty:jcxx:list` → 提示无权限、token 被清、停在登录页 |
| 10 | 未绑定公寓 | `gyid` 为空 → 提示未绑定公寓,停在登录页 |
| 11 | 会话过期 | 手改 Cookie 或等 30 分钟 → 任一请求 `401` → 直接回登录页(**无确认框**),`reason=expired` 文案正确 |
| 12 | 并发 401 单飞 | 同时打 12 个 `/duty/**` 请求全部 `401` → 只弹 1 次提示、只跳 1 次 |
| 13 | 网络中断 | 拔网线(非 401)→ **不跳登录页**,顶部显示"网络中断,正在重试",恢复后自动继续 |
## 10. 对既有文档与计划的改动
表 12-12 需要同步的四处
| 文档 | 改动 | 幅度 |
| --- | --- | --- |
| `02` §6 | 第一条"不做独立登录页"改为"做 `/duty/login` 独立页,但复用 `/login` 认证、不独立签发 token";表 2-8 第 2/3 环节补"登录页白名单 `/duty/login`" | 1 段 |
| `08` 表 8-4 | 新增 `差-D5`(独立登录页) | 1 行 |
| `07` 表 7-8 | 第 2 步"登录可通"细化为四个闸门(表 12-1) | 1 行 |
| `11` 表 11-7/11-8/11-9 | 新增 `T-FE-13`(登录页,1.5 人日)、`T-FE-14`(守卫与失效重定向,1 人日)、`T-BE-09`(`DutyAccessInterceptor`,0.5 人日),并入 R1 | 3 行 + 合计 34.5→37.5 |
**不新增的东西**:不新增后端认证接口、不新增错误码(§3 全部映射既有 `msg`)、不新建 `duty_login` 表、不引入 OAuth/SSO、不做密码自助重置。
**提交纪律**:本轮只产出文档,不改代码、不执行 DDL、不 `git commit`。
@@ -0,0 +1,233 @@
# 13 第一轮开发(R1)交付说明
> **后续**:R2 已交付,关闭了本文的 L-2(命令通道)与 L-5(技术基线),并完成 L-8 的详情部分,见 `14`。
> **本轮代码基线**:`dev/RuoYi-Vue-master`(后端 `ruoyi-apt` 模块,包 `com.ruoyi.apt.duty`)、`dev/RuoYi-Vue2-master`(前端 `src/views/duty/**`)、`dev/sql/apt_rev01/31_duty_permissions.sql`。
> **设计依据**:`11` 表 11-7/11-8/11-9(R1 任务与退出条件)、`12` 表 12-1~12-11(登录与准入)、`02` 表 2-2/2-9(包职责与权限点)、`04` 表 4-2(`ryzt` 呈现)。
> **本轮不新增**:不新增表、不新增跨端接口编号、不引入新依赖、不改 RuoYi 认证实现。
## 1. 本轮目标
一句话:**让值班员能登录、能看到自己公寓当日的叫乘任务,并且看得懂**。
三条可判定目标:
| # | 目标 | 判定方式 |
| --- | --- | --- |
| 1 | 未登录进不去盯控台 | 直接访问 `/duty/jcxx?jcid=…` 必须落到 `/duty/login?redirect=…`,登录后回到原深链 |
| 2 | 列表与统计口径唯一 | 行状态由服务端 `DutyStateDeriver` 派生并下发 `uiState/stateLabel/alarmType/overdue/urgent`,前端不再自行拼装 |
| 3 | 每行都能解释"为什么这样显示" | 同一份 `gy_jcxx` 数据在接口、单测、页面三处表现一致(表 13-3 的 8 条断言) |
**本轮明确不做**(防止"半成品可点"):命令通道(STOP/RE_CALL/MARK_LEAVE)、WebSocket 实时推送、任务详情抽屉、语音临叫、导出、动态菜单。
## 2. 功能范围与优先级
表 13-1 R1 范围(P0 = 不做完不能演示;P1 = 本轮做但可降级)
| # | 功能 | 优先级 | 本轮交付 | 说明 |
| --- | --- | --- | --- | --- |
| 1 | 值班员登录页 `/duty/login` | P0 | ✅ | 账号/密码/验证码、字段级校验、11 类失败文案、记住账号(不记密码) |
| 2 | 准入四闸门 | P0 | ✅ | 会话 → `duty:jcxx:list` → `gyid`(后端 `AptContext`)→ 路由 |
| 3 | 未登录/失效重定向 | P0 | ✅ | 守卫 + 401 分支;带 `redirect` 与 `reason` |
| 4 | 盯控台列表 `/duty/jcxx` | P0 | ✅ | 默认"当日 + 需处理",紧急优先排序,上限 100 |
| 5 | 状态派生与脱敏 | P0 | ✅ | 后端唯一实现,9 个投影态;姓名保留首字 |
| 6 | 吸底统计与紧急卡片 | P0 | ✅ | 服务端 `counts`,避免分页后前端算不准 |
| 7 | 双密度(适老/正常)+ 双布局(卡片/列表) | P1 | ✅ | `localStorage` 记忆,切换不改数据与筛选 |
| 8 | 筛选与搜索 | P1 | ✅ | 需处理/全部/已完成;房间号/姓名/车次,300 ms 防抖 |
| 9 | 时钟偏差提示 | P1 | ✅ | 以 `counts.serverTime` 为基准,>5 s 黄条 |
| 10 | 退出登录 | P1 | ✅ | 清 token 后回登录页 |
| 11 | 60 s 兜底轮询 | P1 | ✅ | 实时推送在 R3 接入,本轮先用轮询保证数据不僵死 |
| — | 命令、实时、详情、语音、导出、动态菜单 | — | ❌ | 见 §9 遗留 |
## 3. 角色与核心使用场景
表 13-2 三个角色
| 角色 | 谁 | 本轮能做什么 | 不能做什么 |
| --- | --- | --- | --- |
| 值班员 | 公寓值班台操作员 | 登录、看当日任务与统计、切换密度/布局、搜索、退出 | 任何写操作(本轮无写接口,按钮不渲染) |
| 公寓管理员 | 管理端用户 | 创建/停用值班员账号、分配 `duty:*` 权限、按需授予角色 | 不能代替值班员登录(无代登录入口) |
| 运维 | 部署与排障 | 校时、看 `sys_logininfor`、执行 `31_duty_permissions.sql` | 无业务页面入口 |
三个核心场景:
1. **早班开场**:值班员登录 → 落 `/duty/jcxx` → 先看"需要马上处理"是否为空 → 按"需处理"逐条盯。
2. **深链回到原处**:会话过期时正在看某条任务 → 401 → 登录页(`reason=expired`)→ 重新登录 → 回到原 `jcid` 深链,筛选与布局不变。
3. **无权限账号**:管理端误把普通账号指到值班台 → 登录成功但无 `duty:jcxx:list` → 提示"没有值班台权限",token 被清,停在登录页(不出现半渲染的盯控台)。
## 4. 对接接口与约束
表 13-3 本轮对接的接口(全部沿用既有约定,无新增编号)
| # | 接口 | 权限点 | 入参 | 出参 | 落点 |
| --- | --- | --- | --- | --- | --- |
| 1 | `GET /captchaImage` | 免登录 | — | `captchaEnabled`/`uuid`/`img` | 登录页 |
| 2 | `POST /login` | 免登录 | `username`/`password`/`code`/`uuid` | `token` | 登录页 |
| 3 | `GET /getInfo` | 会话 | — | `user`/`roles`/`permissions` | 登录页 + 守卫 |
| 4 | `GET /getRouters` | 会话 | — | 菜单树 | 守卫 |
| 5 | `GET /duty/jcxx` | `duty:jcxx:list` | `scope`(need/all/done)、`keyword`、`pageNum`、`pageSize≤100` | `rows[]` + `total` | 盯控台 |
| 6 | `GET /duty/jcxx/counts` | `duty:jcxx:list` | — | 6 个计数 + `serverTime` | 盯控台 |
| 7 | `POST /logout` | 会话 | — | — | 退出 |
表 13-4 六条外部约束(违反即返工)
| # | 约束 | 本轮怎么守住的 |
| --- | --- | --- |
| 1 | `gyid` 只从部署环境注入,绝不接受请求参数 | 服务层统一 `AptContext.getGyId()`;`/duty/**` 无任何 `gyid` 入参 |
| 2 | 状态字段(`ryzt`/`finish_jc`/`finish_ly`/`stoped`)只有 `apt.duty.state` 能写 | `duty` 包**只有 SELECT**,无 `insert/update/delete` 语句,ArchUnit 与 SQL 契约测试双保险 |
| 3 | 列表 `pageSize` 硬上限 100 | 复用 `AptBaseController.startAptPage()` |
| 4 | 列表只读不写审计 | 值班台 60 s 轮询若逐次落 `duty_operation_log` 会淹没审计表;审计只留给命令与导出 |
| 5 | 时间窗由服务端定,客户端不可传 | 只暴露 `scope/keyword`;窗口在服务层固定"当日",无 `jcsj` 的行始终保留 |
| 6 | 认证不魔改框架 | 复用 `/login`;失败按 `i18n/messages.properties` 原文映射,不新增错误码 |
## 5. 交付标准与达成情况
表 13-5 三级交付标准(**如实标注**,未达成不写成达成)
| 级别 | 定义 | 本轮状态 | 证据 |
| --- | --- | --- | --- |
| **可测试** | 自动化用例可复现跑通 | ✅ 达成 | `mvn -o -pl ruoyi-apt -am test` → **269 passed**(251 基线 + 18 新增);前端跳转白名单 10 例脚本通过 |
| **可编译/可构建** | 后端 `mvn compile` 通过;前端 SFC 模板可编译、脚本可解析 | ✅ 达成 | 后端 `BUILD SUCCESS`;前端 `vue-template-compiler` + `@babel/parser` 校验 `ALL OK` |
| **可运行** | 起服务、真实登录、看到真实数据 | ⚠️ **未验证** | 本环境无 MySQL/Redis/Asterisk,未启动 `apt-admin`;未执行 `31_duty_permissions.sql` |
| **可演示** | 现场按脚本演一遍 | ⚠️ **待执行** | 演示脚本见 §7,需在有库的环境跑一次并录像 |
一句话结论:**代码与单测这一层已交付;"能跑起来"依赖部署环境,本轮未取得运行证据,不能对外宣称"已上线可用"。**
## 6. 任务分解与完成状态
表 13-6 任务状态(编号沿用 `11` 表 11-7/11-8)
| # | 任务 | 状态 | 交付物 | 备注 |
| --- | --- | --- | --- | --- |
| `T-BE-01` | 模块骨架 + 权限点脚本 + ArchUnit 断言 | 🟡 部分 | `duty/` 包结构、`31_duty_permissions.sql` | 权限脚本已出;`DutyAccessInterceptor` 未新建(见 §8 偏差 1);ArchUnit 断言沿用既有 6 条 |
| `T-BE-02` | `GET /duty/jcxx` | ✅ 完成 | `DutyJcxxController`、`DutyJcxxQueryServiceImpl`、`DutyJcxxMapper(+XML)` | 默认当日 + 需处理;紧急优先;≤100 |
| `T-BE-03` | 详情 + 时间线 | ⬜ 未开始 | — | 抽屉未出稿,按 `11` 表 11-5 推至 R2 |
| `T-BE-04` | 命令通道 8 命令 | ⬜ 未开始 | — | R2 与 `apt.duty.state` 幂等门面同批次 |
| `T-BE-05` | `GET /duty/jcxx/counts` | ✅ 完成 | 同上 | `G-UI-04` 方案 A 落地 |
| `T-BE-06` | `/ws/duty` | ⬜ 未开始 | — | R3 |
| `T-BE-07` | 报警事件 | ⬜ 未开始 | — | R3 |
| `T-BE-08` | 交接班占位 | ⬜ 未开始 | — | R4 |
| `T-BE-09` | 准入拦截器 | 🟡 替代实现 | `@PreAuthorize('duty:jcxx:list')` + `AptContext` | 见 §8 偏差 1 |
| `T-FE-01` | 入口与路由骨架 | 🟡 部分 | `router/index.js` 两条静态路由 | 动态菜单(`sys_menu`)未落,属 R2 |
| `T-FE-02` | 设计令牌与两版密度 | ✅ 完成 | `console.vue` 内 `--fs-*/--tap/--bd` + `localStorage duty.mode` | 未抽成独立 scss(单页内联,R2 抽 `styles/duty-tokens.scss`) |
| `T-FE-03` | 顶栏 | ✅ 完成 | 状态徽标、时钟、值班员、刷新/退出、版本切换 | 系统状态先按 `counts.alarmCount` 简化,`/duty/health` 接 R2 |
| `T-FE-04` | 卡片 + 列表双布局 | ✅ 完成 | `console.vue` | 切换保留筛选/搜索/滚动位置(未做滚动位置恢复) |
| `T-FE-05` | 状态派生纯函数 | 🟡 后端实现 | `DutyStateDeriver` + 8 条单测 | 前端不再重复实现,直接消费 `uiState`(口径更稳) |
| `T-FE-06` | 筛选 + 搜索 + 空态 + 紧急卡片 | ✅ 完成 | 同上 | 搜索走后端(300 ms 防抖),非纯本地过滤 |
| `T-FE-07` | 命令通道 | ⬜ 未开始 | — | R2 |
| `T-FE-08`~`09` | WS 客户端 / 告警音 | ⬜ 未开始 | — | R3 |
| `T-FE-10` | 三个弹窗 | ⬜ 未开始 | — | R4(停止弹窗依赖 R2 命令) |
| `T-FE-11` | 降级与置灰 | ⬜ 未开始 | — | R4 |
| `T-FE-12` | 无障碍走查 | 🟡 部分 | 56 px 点击区、4 px 焦点环、1024 断点 | 未做真实键盘全流程与对比度测量 |
| `T-FE-13` | 登录页 | ✅ 完成 | `views/duty/login.vue` | 11 类文案映射、验证码刷新、记住账号 |
| `T-FE-14` | 准入守卫与失效重定向 | ✅ 完成 | `permission.js`、`request.js`(401 duty 分支)、`utils/dutyRedirect.js` | 管理端 401 行为保持不变 |
图 13-1 本轮落点(文件级)
```mermaid
flowchart LR
subgraph BE["ruoyi-apt(后端)"]
C["duty/controller<br/>DutyJcxxController"]
S["duty/service<br/>DutyJcxxQueryServiceImpl"]
D["duty/domain<br/>DutyStateDeriver / DutyTaskView"]
M["duty/mapper<br/>DutyJcxxMapper(+XML)"]
end
subgraph FE["RuoYi-Vue2(前端)"]
L["views/duty/login.vue"]
P["views/duty/console.vue"]
G["permission.js / dutyRedirect.js"]
end
DB[("gy_jcxx(只读)")]
C --> S --> M --> DB
S --> D
L -->|"POST /login"| C
G -->|"未登录 → /duty/login"| L
P -->|"GET /duty/jcxx(/counts)"| C
```
## 7. 验证记录
表 13-7 已执行的验证
| # | 验证 | 命令 / 方法 | 结果 |
| --- | --- | --- | --- |
| 1 | 新增 18 条单测 | `mvn -o -pl ruoyi-apt -am "-Dtest=Duty*Test" "-Dsurefire.failIfNoSpecifiedTests=false" test` | Tests run: 18, Failures: 0, Errors: 0 |
| 2 | 全量回归(防踩他人) | `mvn -o -pl ruoyi-apt -am test` | **Tests run: 269**, Failures: 0, Errors: 0, BUILD SUCCESS |
| 3 | 前端 SFC 语法 | `vue-template-compiler` 编译模板 + `@babel/parser` 解析脚本(7 个文件) | ALL OK |
| 4 | 跳转白名单 | Node 脚本 10 个用例(空值/编码/`//evil.com`/`http://`/`/apt/**`/自指/`javascript:`) | 10/10 通过 |
| 5 | 权限脚本契约 | `DutySqlContractTest` 4 条 | 通过(7 个 `duty:*`、可重放、无 C 菜单、不写业务表) |
**未执行**(必须写清,避免误读为已验):`apt-admin` 启动、`/login` 真实登录、`/duty/jcxx` 联调、SQL 执行、浏览器走查、断网/时钟偏差实测、跨公寓越权实测。
演示脚本(待在有库环境执行并录像):
1. 浏览器开 `http://<host>/duty/jcxx?jcid=JC-DEMO-1` → 应落到 `/duty/login?redirect=%2Fduty%2Fjcxx%3Fjcid%3DJC-DEMO-1`;
2. 输错误验证码 → 提示"验证码不正确,已刷新",图片变化、账号保留;
3. 正确登录 → 回跳到 `?jcid=JC-DEMO-1`,顶栏显示值班员与时钟;
4. 切"正常版"与"列表显示" → 数据不变,刷新页面后仍记住;
5. 搜索 `305` → 只剩 305 房;清空 → 恢复;
6. 退出登录 → 回登录页,按浏览器后退不能回到盯控台。
## 8. 遇到的问题与解决方案
表 13-8 六个真实问题(含两次"文档与代码不一致")
| # | 问题 | 现象 | 解决 | 后续影响 |
| --- | --- | --- | --- | --- |
| 1 | `mvn` 不在 PATH | 直接执行 `mvn` 报"无法识别" | 用本机 Maven Wrapper 分发(`~/.m2/wrapper/dists/apache-maven-3.9.12`)以绝对路径调用 | 交付命令写入 §7,评审可复现 |
| 2 | **技术基线文档与代码不一致** | 文档写 JDK 1.8 + Spring Boot 2.5.15,实际 `pom.xml` 是 `java.version=17` + Spring Boot 4.1.0;用 JDK 8 编译报"无效的目标发行版: 17" | 构建改用 JDK 21(兼容 17 目标);**文档待修订**,本轮不改 `02` 表 2-1 | 已登记为遗留 L-5,须在 R2 前统一口径 |
| 3 | 管理端 Mapper 无法表达值班台口径 | `AptJcxxMapper` 的筛选是三条独立 AND(房间/姓名/车次),无法表达"任一项命中";也无法表达"需处理 vs 已完成"这种 OR 口径 | 新建 `duty/mapper/DutyJcxxMapper`(只读投影),不动 IT-5 的管理端 SQL | 两端各一份 SQL,改一端不影响另一端 |
| 4 | 时间窗把"刚入住的人"过滤掉 | 默认"当日"窗口用 `jcsj between from and to`,而刚入寓、还没排任务的行 `jcsj is null` | SQL 写成 `(jcsj IS NULL OR (jcsj >= from AND jcsj < to))`,并在 `DutyJcxxQueryServiceImpl` 注释写明理由 | 值班台不会再"看不到人" |
| 5 | 脱敏与搜索冲突 | 姓名返回脱敏后,值班员按全名搜不到 | SQL 出原始 `xm`;**过滤用原始值,返回体用脱敏值**(`DutyTaskView.fromMap` 统一脱敏) | 单测 `keywordMatchesRoomFullNameAndTrainThenKeepsMaskedOutput` 固定该行为 |
| 6 | 现有 401 处置违反需求 | `request.js` 弹"继续留在该页面?",选取消就留在过期页;且跳 `/index` | 新增"路径以 `/duty` 开头"分支:`FedLogOut` 后直跳 `/duty/login?redirect=…&reason=expired`;管理端分支保持原样 | 值班台不再出现"过期还能看"的假象 |
**两处主动偏差**(设计与实现不完全一致,需评审确认):
1. **未新建 `DutyAccessInterceptor`(`12` §1 曾写)**:`/duty/**` 的 Controller 已用 `@PreAuthorize("@ss.hasPermi('duty:jcxx:list')")`,`gyid` 由既有 `AptContextInterceptor` 注入,再加一层拦截器只是重复校验。**结论**:以"注解 + `AptContext`"为准,`12` §1 的拦截器描述在 R2 评审时同步删除或改为"可选增强"。
2. **列表与统计不写审计**(`04` 表 4-14 只要求导出必写):理由见 §4 第 4 条。
## 9. 遗留问题
表 13-9 遗留(按"是否阻塞 R2"排序)
| # | 遗留 | 影响 | 计划 |
| --- | --- | --- | --- |
| L-1 | 无运行证据(未起服务、未连库) | 不能宣称"可运行" | R2 开始在联调环境跑通 §7 脚本 |
| L-2 | 命令通道未开(STOP/RE_CALL/MARK_LEAVE) | 值班员只能看不能动,业务未闭环 | R2 第一优先 |
| L-3 | 无实时推送(60 s 轮询兜底) | 报警最大 60 s 延迟,不满足 P0 报警 | R3 |
| L-4 | 菜单仍为静态路由 | 换角色/换菜单需改代码,与 `02` 表 2-6 的"`sys_menu` 双载体"不符 | R2 与 C 菜单一起落 |
| L-5 | **`02` 表 2-1 技术基线未修订**(JDK 17 / Spring Boot 4.1 实际值) | 新人按文档会装错 JDK | 文档修订,R2 前完成 |
| L-6 | 未做 E2E/浏览器走查;无前端单测框架(项目未装 eslint/jest) | 交互回归只能靠人 | R2 引入最小 E2E(Playwright 或手工脚本化) |
| L-7 | 密码锁定倒计时、验证码失败次数提示为前端推算 | 与后端锁定窗口可能差几十秒 | R2 视需要改为服务端返回 `lockRemainSeconds` |
| L-8 | 详情/时间线、导出、语音、交接班 | 功能缺口 | R2~R4 按 `11` 表 11-9 |
| L-9 | 跨公寓越权、并发 100 条、SSE 断流未压测 | 容量与隔离未证 | R3 与压测一起做 |
## 10. 下一轮计划(R2)
表 13-10 R2 范围与退出条件
| # | 内容 | 依赖 | 退出条件 |
| --- | --- | --- | --- |
| 1 | 命令通道 `POST /duty/jcxx/{jcid}/commands`:`STOP`/`RE_CALL`/`MARK_LEAVE`/`CANCEL`/`FORCE_FINISH` | `apt.duty.state`(已具备) | 同 `commandId` 连发 3 次只有 1 条 `duty_manual_action`;非法态 `40902`;`409xx` 有中文文案 |
| 2 | 停止叫乘二次确认(原因必选,"其他"≥2 字) | 上文 1 | 未选原因不能提交,`Esc` 关闭不提交 |
| 3 | 按钮显隐按 `ryzt` 收缩(`G-UI-05`) | 上文 1 | `ryzt∈{1,2}` 才有"停止",`ryzt∈{3,4,5}` 只有"登记离寓" |
| 4 | 任务详情抽屉 + 时间线(`T-BE-03`) | 抽屉出稿 | `GET /duty/jcxx/{jcid}`+`timeline` 可用,`40401` 正确 |
| 5 | 动态菜单与 `sys_menu`(`L-4`) | 上文 1 | 换角色后菜单变化无需改代码 |
| 6 | 技术基线文档修订(`L-5`) | — | `02` 表 2-1 与实际 `pom.xml` 一致 |
| 7 | 联调环境跑通 §7 演示脚本并录像(`L-1`) | 环境 | 6 步全过 |
## 11. 可追溯索引
表 13-11 文档 → 代码 → 用例
| 设计条目 | 代码文件 | 对应用例 |
| --- | --- | --- |
| `11` 表 11-2(状态派生) | `duty/domain/DutyStateDeriver.java`、`DutyUiState.java` | `DutyStateDeriverTest`(8 例) |
| `11` 表 11-4(列表/统计落点) | `duty/controller/DutyJcxxController.java` | `DutyJcxxQueryServiceTest`(6 例) |
| `11` 表 11-6 `G-UI-04`(计数不准) | `resources/mapper/apt/DutyJcxxMapper.xml#selectDutyCounts` | `DutyJcxxQueryServiceTest#countsTolerateNullAndBigDecimal` |
| `12` 表 12-3(字段校验) | `src/views/duty/login.vue` | 前端脚本(未自动化) |
| `12` 表 12-6(跳转白名单) | `src/utils/dutyRedirect.js` | Node 脚本 10 例 |
| `12` 表 12-8(失效处置) | `src/permission.js`、`src/utils/request.js` | 待 R2 E2E |
| `02` 表 2-9(权限点) | `sql/apt_rev01/31_duty_permissions.sql` | `DutySqlContractTest`(4 例) |
**提交纪律**:本轮未执行 `git commit` / `git push`;代码改动集中在 `duty` 包、`src/views/duty`、两条守卫相关文件与一个 SQL 脚本,清单见 §6。
@@ -0,0 +1,163 @@
# 14 第二轮开发(R2)交付说明
> **执行阶段**:R2 已完成代码与用例层交付;R1 基线见 `13`。本轮把值班台从"只能看"推到"能操作且留痕",并把 R1 遗留的两项(技术基线、详情抽屉)一并关掉;**唯一未关闭的是"运行证据"**(无 DB/Redis/AMI 环境)。
> **设计依据**:`11` 表 11-9(R1~R5 排期)、表 11-6(`G-UI-01/02/05`)、`04` 表 4-6(命令矩阵)、表 4-7(幂等与响应)、`02` 表 2-9(`duty:call:*`)、`12` 表 12-1(准入)。
> **本轮不新增**:不新增表、不新增错误码、不改 `apt.duty.state` 状态机、不引入前端新依赖。
## 1. 本次完成内容(对 `13` 表 13-10 的逐项交付)
表 14-1 R2 七项承诺的达成情况
| # | R2 承诺(`13` 表 13-10) | 本次交付 | 状态 |
| --- | --- | --- | --- |
| 1 | 命令通道 `POST /duty/jcxx/{jcid}/commands`:`STOP`/`RE_CALL`/`MARK_LEAVE`/`CANCEL`/`FORCE_FINISH` | 新增 `DutyCommandType`、`DutyCommandRequest`、`IDutyCommandService`/`DutyCommandServiceImpl`、控制器 1 个端点;委托 `IAptDutyCommandService` 执行 | ✅ |
| 2 | 停止叫乘二次确认(原因必选,"其他"≥2 字) | 前端原因弹窗(4 选项 + 其他需 ≥2 字)、默认焦点在"取消"、`Esc` 关闭不提交 | ✅ |
| 3 | 按钮显隐按 `ryzt` 收缩(`G-UI-05`) | 前端 `actionsOf(row)` 单一映射;`TODO/CALLING/ALARM_CALL/LEAVE/ALARM_LEAVE/TALKING/STOPPED` 各自动作见 §4 | ✅ |
| 4 | 任务详情抽屉 + 时间线(`T-BE-03`) | `GET /duty/jcxx/{jcid}`、`GET /duty/jcxx/{jcid}/timeline` + 前端右侧抽屉(基本信息/会话/时间线/最近人工动作 + 强制结束入口) | ✅ |
| 5 | 动态菜单与 `sys_menu`(`13` L-4) | **不采用**:值班台是整屏单页,菜单无增量价值;权限点已承载准入。登记为 `差-D6`(§7) | ⚠️ 决策变更 |
| 6 | 技术基线文档修订(`13` L-5) | `02` 表 2-1 四行改为实际值(JDK 17 / Spring Boot 4.1.0 / RuoYi 3.9.2 / MyBatis-SB 4.1.0)+ 修订说明 | ✅ |
| 7 | 联调环境跑通演示脚本并录像(`13` L-1) | **未执行**:本环境无 MySQL/Redis/Asterisk,无法启动 `apt-admin` | ❌ 保留 |
**一句话状态**:R2 功能面 6/7 完成、1 项经决策改口径、运行证据仍缺;R3(实时推送)可以开始,但评审前必须有一个人在有库环境跑一遍 §8 的脚本。
## 2. 命令接口契约
表 14-2 本轮新增的三个端点(均在 `DutyJcxxController`)
| # | 方法与路径 | 权限点 | 入参 | 出参要点 | 失败码 |
| --- | --- | --- | --- | --- | --- |
| 1 | `POST /duty/jcxx/{jcid}/commands` | 5 个 `duty:call:*` 任一(粗判)+ 命令对应点(精判) | `commandType`、`commandId`、`reason`、`expectedState`(可选)、`leaveType`(可选) | `jcid`、`commandId`、`actionId`、`accepted`、`duplicated`、`stateAfter`、`ryztBefore`、`ryztAfter`、`uiState`、`stateLabel` | `40001`/`40301`/`40401`/`40902`/`40907` |
| 2 | `GET /duty/jcxx/{jcid}` | `duty:jcxx:query` | — | 基本信息(脱敏姓名)+ `session` + `manualActions`(≤10) + `uiState/stateLabel/alarmType` | `40401` |
| 3 | `GET /duty/jcxx/{jcid}/timeline` | `duty:jcxx:query` | — | `at`/`type`/`source`/`summary` 列表(倒序) | `40401` |
**双层权限**的必要性:注解 `hasAnyPermi` 只能回答"是不是值班员",回答不了"这个值班员能不能强制结束"。精确判定放在服务层,返回 `40301` 并带上缺失的权限点名称,避免值班员看到"500 未知错误"。
## 3. 命令-状态-权限矩阵
表 14-3 每个命令的前置态、权限与原因要求
| 命令 | 权限点 | 服务端允许的前置(由 `apt.duty.state` 判) | 原因 | 前端出现位置 |
| --- | --- | --- | --- | --- |
| `STOP` | `duty:call:stop` | `ryzt ∈ {1,2}` 且 `finish_ly=0` | **必填**(≥2 字) | `CALLING` 主按钮、`ALARM_CALL` 次按钮 |
| `RE_CALL` | `duty:call:recall` | `stoped=1` 或 `ryzt=2` | 可空(服务端补默认) | `ALARM_CALL` 主按钮、`STOPPED` 主按钮 |
| `MARK_LEAVE` | `duty:call:markLeave` | `finish_ly=0` | 可空(服务端补默认) | `LEAVE`/`ALARM_LEAVE`/`TALKING` 主按钮、详情抽屉 |
| `CANCEL` | `duty:call:cancel` | `ryzt=0` | **必填** | `TODO` 次按钮 |
| `FORCE_FINISH` | `duty:call:forceFinish` | `finish_ly=0` | **必填** | 仅详情抽屉(`LEAVE`/`ALARM_LEAVE`/`TALKING`) |
**原因口径的一处现实妥协**:`04` 表 4-7 写"`RE_CALL`/`MARK_LEAVE` 可不填原因",但既有 `AptDutyCommandServiceImpl.validate` 对 5 个命令**都**要求 `reason` 非空。为避免值班员点"登记离寓"直接吃 `40001`,值班台在服务层为可不填的命令补默认文案("值班台登记离寓"/"值班台复叫"),**不改状态机**。该差异已登记为 `差-D7`。
## 4. 前端动作矩阵(`G-UI-01/02/05` 的落地)
表 14-4 行状态 → 可见动作(前端唯一实现 `actionsOf`)
| UI 状态 | 主按钮 | 次按钮 | 详情 | 说明 |
| --- | --- | --- | --- | --- |
| `TODO` | — | 取消叫乘 | ✅ | `G-UI-01` 方案 B:不给"立即叫乘",提示"距叫乘还有 N 分钟" |
| `CALLING` | 停止叫乘 | — | ✅ | `G-UI-02` 方案 A:不给"确认接通",接通由 AMI `ConfbridgeJoin` 判定 |
| `ALARM_CALL` | 复叫 | 停止叫乘 | ✅ | 报警行同时保留"停止",避免只能复叫不能弃叫 |
| `LEAVE` | 登记离寓 | — | ✅ | |
| `ALARM_LEAVE` | 登记离寓 | — | ✅ | 详情抽屉内额外提供"强制结束" |
| `TALKING` | 登记离寓 | — | ✅ | |
| `STOPPED` | 重新叫乘 | — | ✅ | 同一命令 `RE_CALL`,文案按状态区分 |
| `IN` / `DONE` | — | — | ✅ | 只读 |
## 5. 幂等与并发
表 14-5 三条保证
| 机制 | 实现 | 失败时的行为 |
| --- | --- | --- |
| **命令幂等** | 前端按 `jcid:命令` 生成一次 `commandId` 并缓存,**成功后才删除**;网络失败重试复用同一键 | 服务端返回 `duplicated=true` 与首次 `actionId`,前端当成功处理(`duty_manual_action` 只有 1 行) |
| **乐观校验** | 前端带 `expectedState`(当前 `ryzt`);服务端先比对再执行 | 不一致返回 `40902 EXPECTED_STATE_MISMATCH`(文案"该行已被他人处理,请刷新后重试"),并立即刷新列表 |
| **本地兜底** | 命令成功后先用返回的 `ryztAfter/uiState` 打补丁,再拉一次列表以服务端为准 | 补丁只影响视觉 200~500 ms,不产生第二份事实 |
**为什么不用按钮禁用当幂等**:值班员会连点两下、也会在 3 秒超时后重试;`commandId` 是唯一能跨请求生效的键。
## 6. 详情与时间线
- **数据来源**:`IAptJcxxService.detail(id)` / `timeline(id)`,只换外壳不改查询;`jcid → id` 由 `DutyJcxxMapper.selectDutyRefByJcid` 在本公寓内解析。
- **裁剪**:详情**不返回** `recordings` 与 `facePhotos`(录音与刷脸照片路径只在管理端可见);姓名仍脱敏(`李四 → 李*`)。
- **字段对齐**:抽屉按真实列名渲染 —— 会话 `callState/startedAt/answeredAt/endedAt/confNo`、人工动作 `occurredAt/actionType/state/reason/requestedBy`、时间线 `at/type/source/summary`。
## 7. 偏差与决策登记
表 14-6 本轮需要评审确认的四项
| # | 偏差 | 原设计 | 实际 | 理由 |
| --- | --- | --- | --- | --- |
| `差-D6` | 值班台不建 `sys_menu` 的 C 菜单 | `02` 表 2-6 / `08` 差-7:两载体动态菜单 | 只用 F 权限点 + 前端静态路由 | 单页整屏,无二级导航;静态路由与动态路由同名会生成两条 `/duty/jcxx`(一条带 Layout、一条整屏),无运行时环境无法验证谁生效,**风险大于收益**。R3 若要接 D3~D10 多页,再统一切动态 |
| `差-D7` | 一键动作也写 `reason` | `04` 表 4-7:`RE_CALL`/`MARK_LEAVE` 原因可空 | 服务层补默认原因文本 | 状态机对 5 命令统一 `reason` 非空;改状态机风险更高 |
| `差-D8` | 不新建 `DutyAccessInterceptor` | `12` §1 曾写新增拦截器 | `@PreAuthorize` + `AptContext.getGyId()` | 同一效果少一层;`gyid` 已由既有 `AptContextInterceptor` 注入 |
| `差-D9` | `FORCE_FINISH` 只放在详情抽屉 | `G-UI-05` 只规定主操作 | 主操作区不出现,抽屉内提供 | 卡片上放"强制结束"太容易误点,且它必须写原因 |
## 8. 验证记录
表 14-7 已执行
| # | 验证 | 命令 / 方法 | 结果 |
| --- | --- | --- | --- |
| 1 | 新增命令用例 | `mvn -o -pl ruoyi-apt -am "-Dtest=Duty*Test" "-Dsurefire.failIfNoSpecifiedTests=false" test` | Tests run: 29(命令 11 + 查询 6 + 派生 8 + 脚本 4),0 失败 |
| 2 | 全量回归 | `mvn -o -pl ruoyi-apt -am test` | **Tests run: 280**, Failures: 0, Errors: 0, BUILD SUCCESS |
| 3 | 前端语法 | `vue-template-compiler` 编译模板 + `@babel/parser` 解析(5 文件) | ALL OK(`console.vue` 模板 243 行) |
**新增 11 条用例覆盖的边界**:原因缺失/过短/过长、逐命令权限(`FORCE_FINISH` 无权限必须 40301 且不触库)、`expectedState` 不符不执行、`jcid` 不存在 40401、不支持的命令列出可用项、`commandId` 缺省兜底与给定复用、`duplicated` 透传、详情脱敏且不带媒体路径、未知 `jcid` 取时间线 404、时间线为空返回空数组。
**未执行**:起服务、真实登录、命令落库、抽屉渲染、断网重试、`40902` 真实并发验证。
## 9. 遇到的问题与解决方案
表 14-8 六个实现期问题
| # | 问题 | 现象 | 解决 |
| --- | --- | --- | --- |
| 1 | 状态机返回值不是 Map | 首次编译报"`AptDutyCommandResult` 无法转换为 `Map`" | 改为按 `getActionId()/isDuplicated()/isAccepted()/getStateAfter()` 取值;**没有为了省事去改状态机签名** |
| 2 | 40001 提示里命令名大小写不一致 | 单测期望 `MARK_LEAVE`,实际输出 `markLeave`(当时用的是权限点截断) | 枚举新增 `allCommands()`,直接列命令名,提示更可读 |
| 3 | 详情要的 `jllx`/`update_time` 在管理端查询里不存在 | 两个字段永远是 `null`,抽屉显示空 | 改为真实存在的 `bizVersion` 与 `receivedAt`;不硬造字段 |
| 4 | 会话/动作/时间线键名与我第一版猜的不一致 | 抽屉"会话状态"空白 | 按 `AptJcxxMapper.xml` 真实别名改为 `callState/startedAt/...`、`occurredAt/actionType/...`、`at/type/summary` |
| 5 | 注解无法表达"逐命令权限" | 只写 `hasAnyPermi` 时,有"停止"权限的人也能"强制结束" | 注解做粗判 + 服务层精判,两条都在,且精判先于任何读写 |
| 6 | 命令成功后行状态短暂不一致 | 本地补丁与列表刷新之间可能看到旧排序 | 先补丁(>200 ms 内有反馈)再强制刷新列表,以服务端快照为最终事实 |
## 10. R2 整体状态与遗留
表 14-9 遗留项状态变化(编号沿用 `13` 表 13-9)
| # | 遗留 | 状态 | 说明 |
| --- | --- | --- | --- |
| L-1 | 无运行证据 | 🔴 保留 | 仍是唯一阻塞"可演示"的项 |
| L-2 | 命令通道未开 | 🟢 关闭 | 5 命令全通,含幂等与乐观校验 |
| L-3 | 无实时推送 | 🔴 保留 | R3 主任务;当前仍 60 s 轮询 |
| L-4 | 菜单为静态路由 | 🟡 改口径 | 见 `差-D6`:本期不做动态菜单,不是遗漏 |
| L-5 | 技术基线未修订 | 🟢 关闭 | `02` 表 2-1 已改为实际值 |
| L-6 | 无 E2E/浏览器走查 | 🔴 保留 | 依赖 L-1 |
| L-7 | 锁定倒计时为前端推算 | 🟡 保留 | 影响小,随登录页打磨 |
| L-8 | 详情/导出/语音/交接班 | 🟡 部分关闭 | 详情与时间线已完成;导出、语音、交接班仍在 R3~R4 |
| L-9 | 越权/并发/断流未压测 | 🔴 保留 | R3 与实时推送一起做 |
| **L-10** | 详情字段未与真实库联调 | 🔴 新增 | 字段名已按 XML 对齐,但未跑过真实数据;§8 脚本必须覆盖 |
## 11. 下一轮(R3)计划
表 14-10 R3 范围与退出条件
| # | 内容 | 依赖 | 退出条件 |
| --- | --- | --- | --- |
| 1 | `/ws/duty` 端点:握手校验 `gyid`、`seq` 单调、`state.changed`/`alarm.raised`/`alarm.cleared`/`session.changed` | `apt.duty.state` 事件 | 本机造一条叫乘,2 s 内行状态变化,页面不刷新 |
| 2 | 前端 WS 客户端:心跳 30 s、退避 1→30 s、乱序丢弃、重连后拉一次快照 | 上文 1 | 拔网线 30 s,5 s 内自动重连,`commandId` 与筛选不丢 |
| 3 | 报警音单例 + TTS 队列 + 首次交互解锁 | 上文 1 | 3 行同时报警只响 1 条,逐行消警各自停 |
| 4 | 轮询收敛:有 WS 时 60 s → 5 min 兜底,断流 90 s 出"数据可能滞后" | 上文 2 | 双通道不重复刷新,不同时打两个请求 |
| 5 | 联调跑通 R1+R2 演示脚本并录像(`L-1`/`L-10`) | 环境 | 脚本 6+4 步全过 |
| 6 | 越权与并发压测(`L-9`) | 上文 1 | 跨公寓读取 40302;500 连接不掉线 |
## 12. 追溯索引
表 14-11 设计 → 代码 → 用例
| 设计条目 | 代码 | 用例 |
| --- | --- | --- |
| `04` 表 4-6(5 命令)/ `02` 表 2-9 | `duty/domain/DutyCommandType.java`、`DutyCommandServiceImpl.java` | `DutyCommandServiceTest`(11 例) |
| `04` 表 4-7(幂等) | `DutyCommandServiceImpl#normalizeCommandId`、`console.vue#commandIdFor` | `blankCommandIdIsGeneratedButGivenOneIsKept` |
| `11` 表 11-6 `G-UI-05` | `console.vue#actionsOf` | 前端脚本(未自动化) |
| `11` 表 11-6 `G-UI-01/02` | `console.vue#actionsOf`(`TODO`/`CALLING` 不给按钮) | 同上 |
| `13` 表 13-10 #4(详情) | `DutyJcxxController#detail/#timeline` | `detailMasksNameAndDropsMediaPaths`、`timelineOfUnknownJcidIs404` |
| `13` 表 13-10 #6(基线修订) | `docs/应用端/02` 表 2-1 | 文档核对(无自动化) |
**提交纪律**:未执行 `git commit` / `git push`;本轮改动 = 后端 `duty` 包 6 个文件 + 1 个 XML + 1 个测试类,前端 2 个文件(`api/duty/jcxx.js`、`views/duty/console.vue`),文档 2 处(`02` 表 2-1、本文)。
@@ -0,0 +1,162 @@
# 15 第三轮开发(R3)交付说明
> **执行阶段**:R3 代码与用例层交付完成(实时推送 + 报警音 + 轮询收敛);仍未取得运行证据。
> **设计依据**:`04` 表 4-12(`/ws/duty` 12 类消息与三条硬约束)、表 4-7(`pushSeq`)、`11` 表 11-9(R3 范围)、`02` 表 2-9(`duty:*`)。
> **本轮不新增**:不新增表、不新增错误码、不改 `apt.duty.state`、不引入前端依赖(音频用 Web Audio 合成,无音频资源文件)。
## 1. 本次完成内容(对 `14` 表 14-10 的六项)
表 15-1 R3 六项承诺的达成情况
| # | R3 承诺(`14` 表 14-10) | 本次交付 | 状态 |
| --- | --- | --- | --- |
| 1 | `/ws/duty` 端点:握手校验 `gyid`、`seq` 单调、4 类业务消息 | `DutyWebSocketConfig` + `DutyHandshakeInterceptor` + `DutyWebSocketHandler` + `DutyPushServiceImpl`;消息 `state.changed`/`alarm.raised`/`alarm.cleared`/`session.changed` | ✅ |
| 2 | 前端 WS 客户端:心跳 30 s、退避 1→30 s、乱序丢弃、重连拉快照 | `src/utils/dutySocket.js`(含 ±20% 抖动、60 s 无 PONG 主动重连) | ✅ |
| 3 | 报警音单例 + TTS 队列 + 首次交互解锁 | `src/utils/dutyAlarm.js`(Web Audio 合成,按 `alarmEventKey` 起停);`console.vue` 首次点击解锁并显示提示 | ✅ |
| 4 | 轮询收敛:有 WS 时 5 min 兜底、断流 90 s 提示 | `applyPollInterval()` 60 s ↔ 5 min 自动切换;`stale` 90 s 黄条 | ✅ |
| 5 | 联调跑通 R1+R2 演示脚本并录像(`L-1`/`L-10`) | 环境无 MySQL/Redis/Asterisk | ❌ |
| 6 | 越权与并发压测(`L-9`) | 需运行环境与压测脚本 | ❌ |
**一句话状态**:值班台从"要点刷新才动"变成"自动更新 + 会响",但**没有在任何真实连接上跑过一次**——这是本轮唯一的硬缺口。
## 2. `/ws/duty` 协议
表 15-2 端点与握手
| 项 | 约定 |
| --- | --- |
| 端点 | `GET /ws/duty`(`ws(s)://<host>/ws/duty`,`server.servlet.context-path` 不变) |
| 令牌 | `?token=<JWT>`(浏览器不能设 Header);也接受 `Authorization: Bearer` |
| 鉴权 | JWT + Redis 会话(`TokenService`)→ 权限点 `duty:jcxx:list` 或 `*:*:*` |
| 公寓域 | **只取部署配置 `apt.gy-id`**,不接受 `?gyid=`;未配置直接 403 |
| 失败码 | 无令牌/令牌失效 `401`;无权限点/未配置公寓 `403` |
| 开关 | `apt.realtime.enabled=false` 时不注册该端点,前端自动退回轮询 |
表 15-3 服务端 → 前端消息
| `type` | `category` / `priority` | `data` | 前端行为 |
| --- | --- | --- | --- |
| `CONNECTED` | SYSTEM / P2 | `sessionId`、`userId`、`gyid`、`heartbeatIntervalSec=30`、`serverTime`、`lastSeq`、`messages[]` | 置"实时已连接";以 `lastSeq` 作为丢重起点 |
| `state.changed` | CALL / P1 | `jcid`、`ryztBefore`、`ryztAfter`、`uiState`、`stateLabel`、`actionId` | 只更新该行(不整表刷新);本地没有该行时拉一次快照 |
| `alarm.raised` | ALARM / **P0** | `jcid`、`alarmType`(`CALL`/`LEAVE`)、`alarmEventKey`、`sound=true` | 行置红 + 起报警音 + TTS |
| `alarm.cleared` | ALARM / P0 | `jcid`、`alarmType`、`alarmEventKey` | 停该条声音;1 s 后拉一次快照校正真实状态 |
| `session.changed` | CALL / P1 | `jcid`、`sessionId`、`state`、`confNo` | 详情抽屉开着且同 `jcid` 时刷新详情 |
| `PONG` | SYSTEM / P2 | `serverTime`、`lastSeq`、`ts`(回显) | 刷新心跳计时;连续 2 个周期没有则主动重连 |
三条与 `04` 表 4-12 对齐的硬约束:
1. **心跳 30 s**,服务端只回 `PONG`,不主动造业务消息;
2. **退避重连** 1/2/4/8/16/30 s + ±20% 抖动,**不 reload 页面**;
3. **推送只是加速**:连上立刻拉一次列表与统计,断流 90 s 出"数据可能滞后",轮询兜底永远在。
## 3. 后端落点
表 15-4 新增文件与职责
| 文件 | 职责 | 关键约束 |
| --- | --- | --- |
| `duty/realtime/DutyWebSocketConfig` | 注册 `/ws/duty`、Origin 白名单、总开关 | 不重复 `@EnableWebSocket`(公寓端已开启,同上下文合并) |
| `duty/realtime/DutyHandshakeInterceptor` | 从 query/Header 取令牌、校验权限点、锁定 `gyid` | 属性快照:角色变更需重连生效 |
| `duty/realtime/DutyWebSocketHandler` | `CONNECTED`、`PING→PONG`、断开摘除 | **不接受业务命令**(防绕过权限与幂等) |
| `duty/realtime/DutyPushMessage` | 在公寓端信封上加 `seq` | 其余字段与 `/apt/ws` 一致,前端可复用分发代码 |
| `duty/realtime/IDutyPushService` / `DutyPushServiceImpl` | 4 类消息 + seq 水位 + 事务后发送 | 投递复用 `AptRealtimeSessionRegistry`;固定投给 `duty:jcxx:list` |
| `ruoyi-framework/config/SecurityConfig` | 放行 `/ws/duty` 握手 | 不放行会被 `anyRequest().authenticated()` 拦成 401 |
| `duty/service/DutyCommandServiceImpl` | 命令成功后推 `state.changed` + 告警事件,响应带 `pushSeq` | 推送异常整体 catch,命令结果不受影响 |
**为什么复用公寓端的 `AptRealtimeSessionRegistry`**:它已经做了 gyid 隔离、权限点过滤、单会话串行发送、失败摘除、`sweepIdle`。再写一份只会让"两个通道的摘除策略"慢慢漂移。差异化通过 `targetPermission` 完成:
表 15-5 两个端点的订阅隔离
| 端点 | 握手权限点 | 消息 `targetPermission` | 结果 |
| --- | --- | --- | --- |
| `/apt/ws`(公寓端) | `apt:realtime:connect` | `apt:jcxx:list` 等 | 管理端值班员收不到 `/ws/duty` 的消息 |
| `/ws/duty`(应用端) | `duty:jcxx:list` | `duty:jcxx:list` | 值班员收不到 `/apt/ws` 的消息 |
## 4. 前端落点
表 15-6 三个文件与集成点
| 文件 | 内容 |
| --- | --- |
| `src/utils/dutySocket.js` | 连接/心跳/退避/`seq` 丢重/状态回调;`CONNECTED.lastSeq` 作为起点;`VUE_APP_WS_BASE` 可覆盖地址 |
| `src/utils/dutyAlarm.js` | Web Audio 双音报警(880/660 Hz,1.6 s 循环)、`ring/stopRing/stopAll`、TTS 单队列、`unlockAudio` 挂起补响 |
| `src/views/duty/console.vue` | 顶栏实时状态与"点击页面开启报警声音"提示;`state.changed` 打行补丁;`alarm.*` 起停声音;90 s 滞后黄条;轮询 60 s ↔ 5 min |
| `vue.config.js` | dev 代理 `^/ws/duty` 开 `ws: true`(否则本地握手被当普通 HTTP) |
**为什么报警音要自己做而不是用 `<audio>`**:①不引入二进制资源,仓库里不出现音频文件;②单例与"按 key 停"必须能在 JS 里精确控制;③Web Audio 不受 `<audio>` 预加载/解码时序影响。代价是首次交互必须解锁,前端已用顶部黄条显式提示。
## 5. 关键设计决策
表 15-7 五个需要知悉的取舍
| # | 决策 | 备选 | 理由 |
| --- | --- | --- | --- |
| 1 | `seq` 按 **gyid 全局**单调递增(`AtomicLong`),不是按连接 | 按 session | 按连接无法回填"命令响应里的 `pushSeq`";全局水位还能让新连接一眼知道起点 |
| 2 | `state.changed` 直接带 `uiState/stateLabel` | 前端自己按 `ryzt` 派生 | 派生表只能有一份实现(`11` 表 11-2),前端复制一份必然漂移 |
| 3 | 推送在 **afterCommit**,seq 在 **登记时** 分配 | 提交后再分配 | 回滚不发消息;但响应要立刻回 `pushSeq`,只能先分配后发送 |
| 4 | `-1` 只表示"未登记"(无租户/登记异常);通道发送失败只记日志 | 统一返回 -1 | `afterCommit` 阶段已经无法改变返回值,口径必须诚实 |
| 5 | 报警解除后 **1 s 拉一次快照** | 用 `alarm.cleared` 里的字段直接改行 | `alarm.cleared` 不带目标 `ryzt`(可能是 1/3/5),猜会猜错 |
## 6. 验证记录
表 15-8 已执行
| # | 验证 | 命令 / 方法 | 结果 |
| --- | --- | --- | --- |
| 1 | 新增 17 条用例 | `mvn -o -pl ruoyi-apt -am "-Dtest=Duty*Test" "-Dsurefire.failIfNoSpecifiedTests=false" test` | Tests run: 46(命令 14 + 查询 6 + 推送 6 + 契约 3 + 脚本 4 + 派生 8 + 端点 5),0 失败 |
| 2 | 全量回归 | `mvn -o -pl ruoyi-apt -am test` | **Tests run: 297**, Failures: 0, Errors: 0, BUILD SUCCESS |
| 3 | 前端语法 | `vue-template-compiler` + `@babel/parser`(5 文件) | ALL OK |
新增用例覆盖:seq 单调与水位、投递权限必须是 `duty:jcxx:list`(不能是 `apt:*`)、报警消息 `P0` + `sound=true`、**事务内不发送、afterCommit 才发**、通道异常不抛、无租户不推、`CONNECTED` 带 `heartbeatIntervalSec=30` 与 `lastSeq`、`PING/PONG`(含纯文本 PING 与 `ts` 回显)、非法连接关闭、断开摘除、以及三条配置契约(`SecurityConfig` 放行、路径注册、推送权限与 afterCommit)。
**未执行**:真实 WS 连接、断网 30 s 重连、报警音实际发声、90 s 滞后提示、500 连接压测、跨公寓越权。
## 7. 遇到的问题与解决方案
表 15-9 五个实现期问题
| # | 问题 | 现象 | 解决 |
| --- | --- | --- | --- |
| 1 | `handleTextMessage` 是 `protected`,测试在别的包 | 编译报"无法访问" | 测试移到 `com.ruoyi.apt.duty.realtime` 包(同包可访问),**没有为了测试把生产方法放宽成 public** |
| 2 | 用具体类打桩 | `mock(DutyPushServiceImpl.class)` 语义含糊 | 改 `mock(IDutyPushService.class)`,与生产注入一致 |
| 3 | `-1` 语义一度自相矛盾 | 通道异常时既想返回 -1 又要保持 seq 已分配 | 明确 `-1` 只表示"未登记",通道失败只记日志;接口注释与用例同步改口径 |
| 4 | 本地开发 WS 握手失败 | dev-server 把 `/ws/duty` 当普通请求 | `vue.config.js` 增加 `ws: true` 的代理;生产由 Nginx upgrade |
| 5 | 浏览器拦截自动播放 | 报警来了没声音,值班员以为没报警 | 首次点击解锁 + 顶部黄条显式提示 + 未解锁时挂起并在解锁后补响 |
## 8. 遗留与下一轮(R4)
表 15-10 遗留状态(编号沿用 `13` 表 13-9)
| # | 遗留 | 状态 | 说明 |
| --- | --- | --- | --- |
| L-1 | 无运行证据 | 🔴 保留 | 连续三轮未闭环,已成为最大风险 |
| L-3 | 无实时推送 | 🟢 关闭 | `/ws/duty` + 4 类消息 + 报警音已交付 |
| L-6 | 无 E2E | 🔴 保留 | 依赖 L-1 |
| L-7 | 锁定倒计时前端推算 | 🟡 保留 | |
| L-8 | 导出/语音/交接班 | 🟡 保留 | 交接班在 R4 |
| L-9 | 压测 | 🔴 保留 | R4 与演练一起做 |
| L-10 | 详情字段未联调 | 🔴 保留 | |
| **L-11** | 报警只由命令路径推送 | 🟡 新增 | Quartz 自动产生的报警(`ryzt=2/4`)目前不会推送,只能靠 5 min 兜底轮询发现;需在 `apt.duty.state` 的状态迁移处补推(R4) |
表 15-11 R4 范围
| # | 内容 | 依赖 | 退出条件 |
| --- | --- | --- | --- |
| 1 | 自动报警推送(`L-11`):状态机迁移处补 `state.changed`/`alarm.*` | 状态机扩展点 | 停掉前端轮询,Quartz 触发的报警 2 s 内到达 |
| 2 | 交接班占位与置灰(`G-UI-06`)、降级开关(`T-FE-11`) | `T-BE-08` | 开关关闭时按钮置灰且无请求 |
| 3 | 临时叫乘按 `G-UI-03` 方案 A 落地 | `ASSIGN_ELEMENT` 子集 | 补要素后 `jllx=1`,不新建行 |
| 4 | 联调跑通 R1~R3 演示脚本(`L-1`/`L-10`) | 环境 | 脚本全过并录像 |
| 5 | 断网 30 s、500 连接、跨公寓越权(`L-9`) | 环境 | 无掉线、无越权 |
## 9. 追溯索引
表 15-12 设计 → 代码 → 用例
| 设计条目 | 代码 | 用例 |
| --- | --- | --- |
| `04` 表 4-12(消息与 seq) | `DutyPushServiceImpl`、`DutyPushMessage` | `DutyPushServiceTest`(6 例) |
| `04` 表 4-12(握手与心跳) | `DutyHandshakeInterceptor`、`DutyWebSocketHandler` | `DutyWebSocketHandlerTest`(5 例)、`DutyRealtimeContractTest`(3 例) |
| `04` 表 4-7(`pushSeq`) | `DutyCommandServiceImpl#execute` | `DutyCommandServiceTest`(14 例,含推送失败隔离) |
| `11` 表 11-9 R3(退避/兜底/声音) | `dutySocket.js`、`dutyAlarm.js`、`console.vue` | 前端脚本(未自动化) |
**提交纪律**:未执行 `git commit` / `git push`。本轮改动 = 后端 `duty/realtime` 6 个新文件 + `DutyCommandServiceImpl` + `SecurityConfig` + 3 个测试类;前端 3 个新文件 + `console.vue` + `vue.config.js`。
@@ -0,0 +1,183 @@
# 16 第四轮开发(R4)交付说明
> **执行阶段**:R4 第 1 项(`15` 表 15-11)代码与用例层交付完成——自动报警推送(`L-11`);仍未取得运行证据。
> **设计依据**:`04` 表 4-12(消息与三条硬约束)、表 4-7(`pushSeq`)、`11` 表 11-2(状态派生唯一实现)、`09` F-D03(`ryzt` 六值)、`15` 表 15-11 第 1 项。
> **本轮不新增**:不新增表、不新增错误码、不新增 Mapper、不改 `apt.duty.state` 的 SQL、不改 `/ws/duty` 协议字段、不加前端依赖、不改前端代码。
## 1. 本轮目标与达成
表 16-1 对 `15` 表 15-11 第 1 项的验收
| # | 目标 | 本次交付 | 状态 |
| --- | --- | --- | --- |
| 1 | 在状态机迁移处补 `state.changed` | `AptDutyStateServiceImpl` 的 8 个迁移方法(`callStart`/`answered`/`callFailed`/`stop`/`recall`/`forceFinish`/`markLeave`/`cancel`)全部接 `DutyTransitionPush` | ✅ |
| 2 | 在状态机迁移处补 `alarm.raised` / `alarm.cleared` | 由迁移前后快照派生 `DutyUiState` 比对,产生/解除才发(`DutyTransitionPush#afterTransition`) | ✅ |
| 3 | Quartz 产生的报警必须能到达 `/ws/duty` | `callFailed` 已推;且 AMI 事件、平板上报、人工命令同一条汇合路径全部覆盖 | ✅(用例断言) |
| 4 | 同一次迁移只发一条 `state.changed` | 命令层(`DutyCommandServiceImpl`)的重复推送已删除 | ✅ |
| 5 | 退出条件:停掉前端轮询,Quartz 触发的报警 2 s 内到达 | 环境无 MySQL/Redis/Quartz,未跑 | ❌ |
**一句话状态**:`L-11` 的**代码缺口**已闭合——报警不再依赖"命令层顺手推一下",而是跟着状态机一起走;但**仍然没有一条真实链路跑通过**(`L-1` 未变)。
## 2. 为什么挂在状态机写入口
`gy_jcxx` 的 `ryzt / finish_jc / finish_ly / stoped` 全仓只有 `AptDutyStateMapper.xml` 能写(架构测试 `AptDutyStateSingleWriterArchTest` 强制),而调用它的有四条路径。R3 只在第一条上挂了推送:
表 16-2 四条触发源的推送覆盖
| 触发源 | 调用链 | R3 | R4 |
| --- | --- | --- | --- |
| 人工命令(值班台 / 管理台) | `DutyCommandServiceImpl` → `AptDutyCommandServiceImpl` → 状态机 | ✅ 命令层自己推 | ✅ 状态机统一推(命令层不再推) |
| **Quartz 叫乘扫描**(`0 * * * * ?`) | `AptDispatchTask#scan` → `AptDispatchScanServiceImpl` → `callFailed` | ❌ **漏** | ✅ |
| AMI / 呼叫事件 | `AptVoiceEventServiceImpl` → `callFailed` | ❌ 漏 | ✅ |
| 平板上报(N-04) | `AptTabletResultServiceImpl` → 状态机 | ❌ 漏 | ✅ |
放状态机的第二个收益:**新增迁移点不必再记得补推送**(R5 若加"到点未离寓自动转 `ryzt=4`",推送自动生效)。
被排除的两个方案:
| 方案 | 排除原因 |
| --- | --- |
| 在 `AptDispatchTask#scan` 里补推送 | 只补了四条路径中的一条,AMI/平板仍旧漏;且下次加触发源又会漏 |
| 状态机发 Spring 事件,`duty` 侧 `@TransactionalEventListener(AFTER_COMMIT)` 接收 | 该阶段事务同步仍处于激活状态,`DutyPushServiceImpl` 会再登记一个 `afterCommit`——而 `afterCommit` 阶段新登记的同步**不会被执行**,消息静默丢失。直接同步调用让 `DutyPushServiceImpl` 在**事务内**登记、提交后发送,语义正确且可测 |
## 3. 代码落点
表 16-3 本轮改动
| 文件 | 改动 | 关键约束 |
| --- | --- | --- |
| `duty/realtime/DutyTransitionPush`(新增) | 迁移后统一推送:派生前后 `DutyUiState`、判报警起停、回填 `seq` | 推送异常只记日志;迁移后快照缺失不猜;`seq` 单线程单条、取走即清 |
| `dispatch/service/AptDutyStateServiceImpl` | 8 个迁移方法:迁移前读快照 → 条件更新 → 迁移后读快照 → `afterTransition` | 仍以"条件更新未命中 = 40902"为准,读快照不参与判定 |
| `duty/service/DutyCommandServiceImpl` | 删除 `pushStateAndAlarm` / `alarmKey`,`pushSeq` 改从迁移桥 `takeStateSeq(jcid)` 取 | 命令层**不再推**,避免同一次迁移发两条 `state.changed`、报警音响两遍 |
| `AptDutyStateServiceTest` | 注入真实迁移桥(mock `IDutyPushService`),新增 3 例 | 断言"状态机 → 值班台"整条单元链路,而不是只断言"调了桥" |
| `DutyTransitionPushTest`(新增 7 例) | 报警起停、不猜、异常隔离、`seq` 一次性 | |
| `DutyCommandServiceTest` | 14 例 → 12 例:报警起停两例迁到迁移桥;推送失败一例改成 `pushSeq=-1` | |
**代价**:每次状态迁移多一次单行主键查询(`selectSnapshot`,走 `gyid + jcid` 的唯一索引)。一行一查,比"新增一列冗余做差量"更不容易错。
## 4. 报警起停矩阵
表 16-4 八个迁移方法 → 消息与声音
| 迁移 | `ryzt` / 标志变化 | 派生 `uiState` | 消息 | 声音 |
| --- | --- | --- | --- | --- |
| `callStart` | 0 → 1 | TODO → CALLING | `state.changed` | — |
| `answered` | 0/1/2 → 3,`finish_jc=1` | → LEAVE | `state.changed` | 前态是报警则 `alarm.cleared` |
| **`callFailed`** | 1 → 2 | CALLING → **ALARM_CALL** | `state.changed` + **`alarm.raised`** | **起响**(`jcid:CALL`) |
| `stop` | `stoped=1`(`ryzt` 保持 1/2) | → STOPPED | `state.changed` | 前态是报警则 `alarm.cleared` |
| `recall` | `stoped` 清零、2 → 1 | → CALLING | `state.changed` | 前态是报警则 `alarm.cleared` |
| `forceFinish` | `finish_jc=1`(`ryzt` 不变) | 不变 | `state.changed` | **不变**(见 §5 缺陷 2) |
| `markLeave` | `finish_ly=1` | → DONE | `state.changed` | 前态是报警则 `alarm.cleared` |
| `cancel` | 0 → `stoped=1` | TODO → STOPPED | `state.changed` | — |
判定顺序完全复用 `DutyStateDeriver`(`11` 表 11-2 的唯一实现):先看 `finish_ly`,再看 `stoped`,最后看 `ryzt`。后端不另写一套判定,前端也不自己拼。
`alarmEventKey` 仍是 `jcid + ":" + alarmType`,与 R3 前端 `dutyAlarm.js` 的起停键一致,**前端无需改动**:报警行不在本地列表时 `applyAlarmRaised` 会先 `fetchList()` 再起响。
## 5. 顺手修掉的两个缺陷
表 16-5 R3 遗留的推送判定缺陷
| # | 缺陷 | 触发场景 | 现象 | 修法 |
| --- | --- | --- | --- | --- |
| 1 | R3 的 `else if (alarmBefore != null)` 分支:**前后态相同也发 `alarm.cleared`** | 对一条 `ryzt=2` 的行重复提交命令(幂等命中)、或 `FORCE_FINISH` 只改 `finish_jc` | 报警音被误停,值班员以为报警已处理 | 改为「前态有报警 **且** 后态不同」才发解除(`DutyTransitionPush`) |
| 2 | `forceFinish` 不清报警音(`DutyStateDeriver` 不看 `finish_jc`) | 强制结束一条叫乘报警行 | 行仍显示"叫乘报警"、声音继续响 | **未改**:这是派生表的既有口径(`11` 表 11-2 只投影 `ryzt/stoped/finish_ly`),要改得先改设计,不能顺手塞进推送层 |
## 6. seq 与 pushSeq 口径
表 16-6 `seq` 在 R4 的归属
| 项 | 口径 |
| --- | --- |
| `seq` 分配 | 仍在 `DutyPushServiceImpl` 登记时分配(按 gyid 全局 `AtomicLong`),R4 未改 |
| 一次迁移的消息数 | 1~2 条(`state.changed`,可能紧跟 `alarm.raised`/`alarm.cleared`),`seq` 连续 |
| 命令响应 `pushSeq` | 取本次迁移 `state.changed` 的 `seq`(迁移桥在该线程登记) |
| `pushSeq = -1` | 本次迁移未登记(读不到新态、推送抛异常、幂等命中未发生迁移)。与 `15` 表 15-7 第 4 项同义 |
| `actionId` | 迁移桥由状态机调用,**固定为 `null`**(状态机不知道、也不该知道命令幂等键);命令响应里的 `actionId` 仍由 `AptDutyCommandServiceImpl` 给出。前端 `console.vue` 不读该字段,无影响 |
## 7. 验证记录
表 16-7 已执行
| # | 验证 | 命令 | 结果 |
| --- | --- | --- | --- |
| 1 | 本轮相关用例 | `mvn -o -pl ruoyi-apt -am "-Dtest=DutyTransitionPushTest,DutyCommandServiceTest,AptDutyStateServiceTest" "-DfailIfNoTests=false" test` | **Tests run: 28**, Failures: 0, Errors: 0 |
| 2 | 全量回归 | `mvn -o -pl ruoyi-apt -am test` | **Tests run: 305**, Failures: 0, Errors: 0, BUILD SUCCESS |
| 3 | 架构测试 | 含在同一次全量里 | 通过(状态字段仍只有 `AptDutyStateMapper.xml` 能写,本轮未加 SQL) |
用例数变化:`AptDutyStateServiceTest` 6 → 9(+3)、`DutyCommandServiceTest` 14 → 12(报警起停两例迁走、失败一例改写)、新增 `DutyTransitionPushTest` 7 → 合计 297 → **305**。
新增断言覆盖:`ryzt 1→2` 推 `alarm.raised` 且键为 `jcid:CALL`、`2→3` 推 `alarm.cleared`、前后态相同不发假解除、`ryzt=4` 推 `LEAVE`、迁移后快照缺失不推且 `pushSeq=-1`、推送抛异常不外抛且 `pushSeq=-1`、`seq` 取走即清且 jcid 不匹配不销毁登记、`callFailed` 同时走公寓端与值班台两条通道。
**未执行**(环境缺 MySQL/Redis/Asterisk,与 R3 同因):真实 WS 连接、停轮询后 Quartz 报警到达时延、报警音实际发声、断网 30 s 重连、500 连接压测、跨公寓越权。
## 8. 实现期问题
表 16-8 四个问题
| # | 问题 | 现象 | 解决 |
| --- | --- | --- | --- |
| 1 | `takeStateSeq` 首版"读即清" | 用错 jcid 读一次就把有效登记销毁,用例 `seqIsOneShotAndBoundToJcid` 直接失败(期望 22 实际 -1) | 改为"jcid 匹配才取走",不匹配只返回 `-1` 不动登记;下次迁移开头也会覆盖,不会泄漏 |
| 2 | `markLeave` 有提前 `return`(房间号缺失) | 推送若写在方法末尾会跟着一起丢 | 推送上移到 `insertLeaveEvent` 之后、房间号判断之前,并加注释说明顺序原因 |
| 3 | 命令层与状态机都想推 | 一次命令会发两条 `state.changed`、TTS 念两遍 | 只在状态机推;命令层改为"取 `seq`",`pushSeq` 语义不变 |
| 4 | `mock(DutyTransitionPush.class)` 是具体类 | 需要用 `when(...).thenReturn(...)` 打桩取序号 | 迁移桥保持无状态外部依赖(只依赖 `IDutyPushService`),单测可直接 new,不引入 Mockito 深桩 |
环境问题(非代码):本机 `mvn` 不在 `PATH`,需显式使用 IntelliJ 自带 Maven(`plugins/maven/lib/maven3`)+ `JAVA_HOME=C:\Users\zfjia\.jdks\ms-21.0.9`(默认 `java` 是 1.8,编不过本项目)。
## 9. 遗留与下一轮(R5)
表 16-9 遗留状态(编号沿用 `13` 表 13-9)
| # | 遗留 | 状态 | 说明 |
| --- | --- | --- | --- |
| L-1 | 无运行证据 | 🔴 保留 | 连续四轮未闭环,仍是最大风险;R4 第 5 项退出条件因此无法判定 |
| L-6 | 无 E2E | 🔴 保留 | 依赖 L-1 |
| L-7 | 锁定倒计时前端推算 | 🟡 保留 | |
| L-8 | 导出/语音/交接班 | 🟡 保留 | 交接班见 `15` 表 15-11 第 2 项 |
| L-9 | 压测/越权 | 🔴 保留 | |
| L-10 | 详情字段未联调 | 🔴 保留 | |
| **L-11** | 报警只由命令路径推送 | 🟢 **关闭** | 四条触发源统一在状态机写入口推送;代价是每次迁移多一次单行查询 |
| **L-12** | `ryzt=4`(离寓报警)**全仓无写入路径** | 🔴 新增 | 只有展示与统计在读它,没有任何 SQL/任务会置 4。本轮只保证"一旦有写入就会推送",离寓报警本身仍是空功能,需单独立项(涉及"到点未离寓"判定窗口与 `AptDutyStateMapper` 新增写入口) |
| **L-13** | `session.changed` 无生产点 | 🟡 新增 | 协议里有该消息(`15` 表 15-3),R3/R4 都没有发送方;`recall` 已生成 `session_id`,接上是小改动,但属于会话视图,留待详情联调一起做 |
| **L-14** | `DutyUiState.IN`(已入寓)在库里不可达 | 🔴 新增 | `gy_jcxx` 的检查约束 `ck_jcxx_ryzt` 只允许 `'0'~'5'`,而 `DutyStateDeriver` 把「`ryzt` 空」判为 `IN`——造数时写空串直接被约束拒绝。要么 DDL 放开空值、要么删掉 `IN` 这个投影,需决策(`11` 表 11-2 有 9 个投影,实际只可能出现 8 个) |
表 16-10 R5 范围(`15` 表 15-11 剩余项,顺序按风险调整)
| # | 内容 | 依赖 | 退出条件 |
| --- | --- | --- | --- |
| 1 | 联调跑通 R1~R4 演示脚本并录像(`L-1`/`L-10`);含"停掉轮询、等 Quartz 触发、2 s 内报警到达"这一条 | 环境(MySQL/Redis/Asterisk/SIP) | 脚本全过 + 录像 |
| 2 | 断网 30 s 重连、500 连接、跨公寓越权(`L-9`) | 环境 | 无掉线、无越权、无串公寓 |
| 3 | 交接班占位与置灰(`G-UI-06`)、降级开关(`T-FE-11`) | `T-BE-08` | 开关关闭时按钮置灰且无请求 |
| 4 | 临时叫乘按 `G-UI-03` 方案 A 落地 | `ASSIGN_ELEMENT` 子集 | 补要素后 `jllx=1`,不新建行 |
| 5 | `ryzt=4` 离寓报警立项(`L-12`) | 需先定判定窗口 | 独立决策单 |
## 10. 追溯索引
表 16-11 设计 → 代码 → 用例
| 设计条目 | 代码 | 用例 |
| --- | --- | --- |
| `15` 表 15-11 第 1 项(`L-11`) | `AptDutyStateServiceImpl`、`DutyTransitionPush` | `AptDutyStateServiceTest`(3 例,含 Quartz 无上下文场景)、`DutyTransitionPushTest`(7 例) |
| `04` 表 4-12(`state.changed` / `alarm.*` 语义) | `DutyTransitionPush#afterTransition` | `DutyTransitionPushTest`(起停矩阵 5 例) |
| `04` 表 4-7(`pushSeq`) | `DutyCommandServiceImpl#execute` + `DutyTransitionPush#takeStateSeq` | `DutyCommandServiceTest`(12 例) |
| `11` 表 11-2(状态派生唯一实现) | `DutyStateDeriver`(未改,被复用) | `DutyStateDeriverTest`(8 例) |
| `09` F-D03(`ryzt` 六值) | 同上 | 同上 |
**提交纪律**:未执行 `git commit` / `git push`。本轮改动 = 后端 3 个生产文件(1 新增 2 修改)+ 3 个测试类(1 新增 2 修改);前端零改动。
## 11. 联调准备期补充(同日)
为了在本地库里造几条叫乘任务、真正打开 `/duty/jcxx` 看一眼,做了两件事,并撞出一个必现故障。
表 16-12 联调准备期改动
| # | 改动 | 原因 | 验证 |
| --- | --- | --- | --- |
| 1 | `DutyJcxxMapper.xml` 的 `dutyColumns` 去掉 `j.jllx AS matchLocked` | `jllx`(交路要素锁定)在 `apt_local_task` 上,`gy_jcxx` 没有这一列。该 Mapper 一旦执行即 `ERROR 1054 Unknown column 'j.jllx'`,意味着**值班台列表从 R1 起就没成功跑过一次**(与 `L-1` 同源) | 用同一段 SQL 在本库执行:改前 1054,改后返回 7 行 |
| 2 | 新增 `dev/sql/local_seed/duty_demo_tasks.sql` | 本地联调演示数据:9 条 `JC-DEMO-*`,覆盖 `ryzt=0/1/2/3/4` + `stoped` + `finish_ly` + `jcsj` 为空;时刻基于 `NOW()`,可重放 | 执行后 `total=9`、`todoCount=3`、`callCount=2`、`alarmCount=2` |
`matchLocked` 目前不下发:值班台锁定功能尚未落地,前端也从未读取该字段(`grep matchLocked` 在 `RuoYi-Vue2-master/src` 命中 0 次)。锁定上线时应从 `apt_local_task` 关联,**不要**再加回 `j.jllx`。
另一处发现登记为 `L-14`:造数时按 `DutyStateDeriver` 的口径写 `ryzt=''` 以表达「已入寓未排任务」,被 `ck_jcxx_ryzt` 直接拒绝,说明 `DutyUiState.IN` 这个投影在当前 DDL 下永远出不来。
**重启才生效**:运行中的实例(IntelliJ 启动,classpath 指向各模块 `target/classes`)仍持有旧 Mapper,已用 `mvn -o -pl ruoyi-apt -am process-resources` 把修正同步到 `target/classes`,但需**重启后端**才会重新加载。
@@ -0,0 +1,103 @@
# 17 测试配置与测试入住(R5)
> **目的**:让值班员在没有平板、没有平台推数据的环境里,用界面把「房间 → 分机 → 入住 → 叫乘」这条链路自己铺出来。
> **位置**:盯控页顶栏「办理入住」+「测试配置」,配置页路由 `/duty/config`。
> **总开关**:`apt.debug.enabled`(环境变量 `APT_DEBUG_ENABLED`)。关闭时所有写接口返回 `40940`,只读的 `/duty/test/status` 仍可用——页面据此提示如何开启,而不是给一个 404。
## 1. 能力清单
表 17-1 四组能力与落点
| # | 能力 | 接口 | 权限点 | 落库 |
| --- | --- | --- | --- | --- |
| 1 | 房间信息录入 | `GET/POST /duty/test/rooms` | `duty:config:read` / `duty:config:room` | `apt_master_room`(补测试基线)+ `gy_rooms`(正式登记)+ SIP 三件套 |
| 2 | 房间 ↔ 分机绑定 | `GET /duty/test/sip/extensions`、`POST /duty/test/sip/bind`、`DELETE /duty/test/sip/bind/{roomNo}` | `duty:config:read` / `duty:config:sip` | `apt_sip_endpoint` / `apt_sip_binding` / `apt_sip_auth` |
| 3 | SIP 服务器(AMI)配置 | `GET/PUT /duty/test/sip/server`、`POST /duty/test/sip/server/probe` | `duty:config:sipServer` | `apt_ast_instance` |
| 4 | 测试办理入住 | `GET /duty/test/checkin/candidates`、`POST /duty/test/checkin` | `duty:checkin:test` | `apt_local_task` + `gy_jcxx` + 住房事件 + 房态(全部由正式服务链路写) |
## 2. 关键设计
表 17-2 四条不可让步的约束
| # | 约束 | 做法 |
| --- | --- | --- |
| 1 | 入住不得绕过服务层 | 不直接 `INSERT gy_jcxx`,而是构造 `AptCheckinApplyCommand`(来源 `DUTY`、设备码 `DUTY-TEST`、核验时刻取服务端当前时间)→ `IAptCheckinService.apply` → `review(APPROVE)`。人脸注册确认、房间可住、同一人不得重复在住、床位/房态/上报队列全部照旧 |
| 2 | 状态列只有状态机能写 | 测试链路只额外写 `gy_jcxx.jcsj/jcfs`(叫乘时刻,线上由 #7 匹配结论算),`ryzt/finish_jc/finish_ly/stoped` 一律不碰;`AptDutyStateSingleWriterArchTest` 仍通过 |
| 3 | AMI 口令不落库 | `apt_ast_instance` 只存 `ami_secret_ref`(环境变量名),表单用正则 `^[A-Z][A-Z0-9_]{2,63}$` 挡掉明文口令;页面只显示「是否已注入」,永不回显值 |
| 4 | 公寓域隔离 | 所有语句按 `gyid` 过滤,`gyid` 取自 `AptContext`,接口不接受 `gyid` 入参 |
**为什么要补 `apt_master_room`**:入住房间下拉(`selectRoomOptions`)与 `requireRoom` 都以平台基线为准,只写 `gy_rooms` 的房间根本选不到。测试房间的 `room_id` 以 `TEST-` 前缀标记,便于识别与清理(删除时先删 `gy_rooms`/SIP,再删基线行)。
## 3. 校验与反馈
表 17-3 前端/后端双层校验
| 字段 | 规则 | 提示位置 |
| --- | --- | --- |
| 房号 | 数字/字母/短横线,≤32;唯一 | 表单下方红字 + 后端 `40901` |
| 床位数 | 1~20 | 表单内联 |
| 分机号 | 3~8 位数字;必须在号段内;不得被占用(**含软删历史**,号码不复用) | 红字 + 后端 `EXTENSION_OUT_OF_POOL` / `EXTENSION_IN_USE` |
| 分机号换号 | 已有分机时改号 → `40906 分机号不可改`,提示先解绑 | 红字 |
| 解绑 | 二次确认,明确「号码不回收」 | 确认框 |
| AMI 端口 | 1~65535 | 表单内联 |
| 口令引用 | 环境变量名形态,否则提示「口令不落库」 | 表单内联 |
| 号段 | 起止同填或同空;起 ≤ 止 | 表单内联 |
| 人员 | 人脸注册未确认 / 已在住 → 选项置灰;强行提交由后端 `40914` / `40932` 拦 | 选项文字 + 红字 |
成功与失败都有明确出口:顶部绿色条(保存成功/已生成任务号)、红色条(后端原文错误)、`ck-result` 面板(入住结果 + 逐条 warnings,例如「未填叫乘时刻」「voice 未开不会真拨号」)。
## 4. 使用步骤(本地联调)
1. 执行 `dev/sql/apt_rev01/33_duty_config_columns.sql`(号段列)、`34_duty_test_seed.sql`(AMI 配置 + 人脸注册台账)、`35_duty_config_permissions.sql`(权限点)。
2. `dev/.env` 增加 `APT_AMI_SECRET=<AMI 口令>`、`APT_DEBUG_ENABLED=true`,重启后端。
3. **重新登录**(权限点随登录令牌下发,旧令牌看不到 `duty:config:*`)。
4. `/duty/config` → 「SIP 服务器」确认地址/端口/用户名/号段,点「探测连通性」。
5. `/duty/config` → 「房间信息」录一间房(分机号留空自动取号)→ 「房间与分机绑定」确认分机。
6. `/duty/jcxx` → 「办理入住」:选人员 + 房间 + 叫乘时刻(建议 2 分钟后)→ 提交,盯控页立刻出现该行。
7. 到点后「到点扫描」把它推进到叫乘中;若 `APT_VOICE_ENABLED=true` 且 AMI 可达,则真正发起 SIP 呼叫。
## 5. 验证记录
表 17-4 已执行
| # | 验证 | 结果 |
| --- | --- | --- |
| 1 | 增量脚本执行 | 33 落 2 列;34 落 AMI 配置(`DOWN` 待重连)+ 5 名人员补齐 CONFIRMED 注册,可入住候选 5 人;35 落 5 个权限点并授予值班员 |
| 2 | 入选人员/房间 SQL | 与测试配置页共用同一段 SQL,`selectCheckinCandidates` 返回 5 行 |
| 3 | 后端编译 | `mvn -o -pl ruoyi-apt -am compile` 通过 |
| 4 | 全量回归 | `Tests run: 305, Failures: 0, Errors: 0, BUILD SUCCESS`(架构测试 `AptMapperGyidArchTest`、`AptDutyStateSingleWriterArchTest` 均通过) |
| 5 | 前端语法 | `vue-template-compiler` + `@babel/parser`:`console.vue` / `config.vue` / `api/duty/test.js` 全部 OK |
| 6 | 运行时验证 | 🟡 部分:浏览器实操已发起过一次 `POST /duty/test/checkin`,撞出 `selectConfirmedReg` 的 1054(见 §7,已修);修复后用「事务内干跑」把入住链路 10 步 SQL 全部验过并回滚 |
## 6. 未验证项与已知限制
表 17-5 交付即声明的边界
| # | 项 | 说明 |
| --- | --- | --- |
| 1 | 页面未实际点过 | 本轮只做静态语法校验与 SQL/服务层验证,浏览器实操留给重启后的联调 |
| 2 | AMI 连通性探测只到 TCP | 不做 AMI 登录握手(依赖 voice 开关与真实 Asterisk),`reachable=true` ≠ 认证成功 |
| 3 | 测试房间写进平台基线 | `apt_master_room` 在生产由平台同步维护;本功能仅在 debug 模式写入,`room_id` 以 `TEST-` 前缀标记,生产必须保持 `APT_DEBUG_ENABLED=false` |
| 4 | 解绑后号码不回收 | 既有设计(`apt_sip_endpoint` 唯一键含历史行),号段 1010-1050 共 41 个号;反复换号会逐步耗尽 |
| 5 | 入住不走上报 | 未接入 #9 outbox 的真实投递(`apt.report.dispatch-enabled=false`),`sync_state` 停在本地态,不影响叫乘链路 |
**提交纪律**:未执行 `git commit` / `git push`。
## 7. 联调期修正(同日)
第一次在浏览器点「办理入住」就暴露了一个 D1 链路的既有缺陷——与 `15` 轮 `j.jllx`、`16` 轮同源:Mapper 引用了表上不存在的列。
表 17-6 联调期修的三处
| # | 问题 | 根因 | 处理 |
| --- | --- | --- | --- |
| 1 | `POST /duty/test/checkin` → `50304 SCHEMA_MISMATCH` / `Unknown column 'deleted' in 'where clause'` | `AptCheckinMapper.selectConfirmedReg` 在 `apt_person_face_reg` 上带 `deleted = 0`,而该表建表即无 `deleted` 列,生命周期由 `reg_state`(APPLIED/CONFIRMED/REJECTED/ARCHIVED)表达。**任何入住申请都会在此处 1054**,与测试入住功能无关 | 去掉该条件并加注释说明列不存在的原因 |
| 2 | 入住下拉能选出「停用/维修」房间,提交后必然 `40928` | `checkinCandidates` 只判「空床」,未判房态;`apply → requireRoom` 判的是 `room_status NOT IN ('4','6')` | 候选过滤补齐 4/6;配置页房间表新增「房态」列(空闲/满/待清扫/停用/部分占用/维修),停用项标黄提示 |
| 3 | 无法确认入住链路还有没有别的坏 SQL | 每次重启才能试一次,代价高 | 在事务里干跑入住链路 10 步(申请单 → 状态迁移 → 叫乘行 → 占床 → 房态 → 离寓事件 → outbox → 审计 → 时间线),最后 `ROLLBACK`;全部通过。干跑中两次失败均为造数取值违反 CHECK(`ck_lt_face`、`ck_lye_source`),按真实取值修正后通过 |
| 4 | `40926 流程态或版本已变更(当前 SUBMITTED / v1)` | 测试链路直接 `apply → review`,漏了 N-43「接单」这一跳:`apply` 只落到 `SUBMITTED`,`review` 只接受 `REVIEWING/APPROVED/ROOM_ADJUSTED`;线上是值班员「点开详情」完成接单。另外接单会把 `biz_version` 从 1 推到 2,乐观锁必须用接单后的版本 | 补 `checkinService.dutyDetail(applyId, operator)` 完成接单,再用其返回的 `bizVersion` 作 `expectedBizVersion`。**不改 `review` 的校验**——放开 SUBMITTED 会把「跳步审核」变成合法路径 |
本库房间现状(干跑时确认):`201`/`303` 房态 4(停用)、`301` 满、`202` 与 `302` 可住——选房时用 202 或 302。
另:`registerCheckin` 已加 `@Transactional`。原先申请与审核是两个独立事务,第 4 条故障在 `apt_local_task` 留下了一张半截 `SUBMITTED` 申请单(`CHK-20261112160645-527953`,RY-003 / 202 房,无 `gy_jcxx` 行、不占床)。该类残留不会阻塞后续测试,现在失败会整体回滚。
**教训**:`deleted` 不是所有表的通用列(`apt_person_face_reg`、`apt_local_task` 之外还有若干),新增/修改 Mapper 时应先 `information_schema.columns` 核对,而不是照抄同库其它表的写法。