从零重构外卖系统(十二):实时通知怎样做到断线可补、已读可恢复、换账号不串消息
系列:Han Menu 外卖系统实践 · 从第一篇开始
系列:Han Menu 外卖系统实践 · PC-4
面向读者:已经理解 PC-1 的会话代数、P6 的持久化通知与 PC-3 的订单作业,希望学习浏览器长连接、游标恢复、多设备并发和可测试异步状态机的开发者。
本篇依据 PC-4 提交
cc79576与docs/PC4_CONTRACT.md编写。代码展示实际关键实现;省略依赖、外围方法或测试装配的片段不是独立完整工程。PC-4 是管理端实时通知阶段,不是后端 P4 未支付订单阶段。本次交付铃铛、页面订单提醒、通知中心、断线补查与显式已读;管理员投递维护、资金和报表等仍属于后续范围。文章引用已有验收结果,没有重新运行测试或验证 Vditor 渲染。
1. 连上 WebSocket,只解决了通知问题的一小部分
PC-3 已经能处理订单,但员工发现新订单仍主要依赖工作台和列表核对。
接入 WebSocket 后,第一版代码很容易只有几行:收到消息,把消息加进数组,弹一个提醒。
真正营业时,问题会很快出现:
- 浏览器断网一分钟,期间的新订单怎么找回来?
- 先收到第 103 条,后收到第 101 条,游标能直接设为 103 吗?
- 同一条消息重发三次,会不会弹三次提示?
- 员工只看了第一页,后台已经补了第二页,点“本页已读”会不会把第二页也确认掉?
- 同一个员工在两台电脑操作,旧页面会不会覆盖新阅读进度?
- 退出后,取票据请求才返回,会不会重新连上上一账号?
- WebSocket 失败,但 HTTP 可用,通知中心是否还能工作?
这些问题需要连接、数据恢复与用户确认三部分共同解决。
PC-4 的基本约束是:实时帧负责提示有变化,HTTP 负责取得连续事实,员工显式确认负责推进已读。
2. 从 P6 继承三种事实,不把它们压成一个“已通知”
P6 后端已经区分通知持久化、实时投递和员工阅读。前端继续保留这个区别。
| 状态 | 说明什么 | 不能说明什么 |
|---|---|---|
| 通知已持久化 | 服务端有一条可补查的业务通知 | 员工已经看到它 |
| 实时投递成功 | 服务端完成了一次推送尝试 | 当前页面一定完整显示或员工已读 |
| HTTP 已拉取 | 客户端验证了一段连续记录 | 员工确认过这段记录 |
| 本人阅读进度 | 该员工已明确确认到某个序号 | 订单已经接单或履约完成 |
查看订单、收到提醒、后台同步完成,都不会自动 PUT 已读。
“已读”也不等于“已处理”。员工可能看过催单,仍然需要进入订单详情判断业务状态。
3. 页面能力保持有界,避免消息列表变成另一个订单数据库
本阶段允许 ADMIN 与 STAFF 使用通知能力。
| 入口 | 实际行为 |
|---|---|
| 顶栏铃铛 | 提示待阅读、跳转通知中心、展示连接状态 |
| 全局页面提醒 | 来单或催单提示,点击打开订单详情 |
| 通知中心待阅读 | 从本人已读下界开始读取一页,明确确认本页 |
| 通知中心全部记录 | 从游标零顺序翻页,可返回本次访问的上一页 |
| 重新连接与同步 | 展示连接、补查、离线和失败,允许手动重试 |
通知列表只包含固定类型、订单 UUID、发生时间和阅读状态,没有顾客姓名、电话和地址。
点击后进入 PC-3 的权威订单详情。通知不携带足够信息让前端直接接单,也不能把旧事件状态覆盖到当前订单。
本阶段没有请求系统桌面通知权限,也没有自动播放声音,提醒使用现有组件主题中的页面通知。
4. 为什么运行器独立于 Pinia 与页面组件
模块结构:
admin/src/modules/notifications/
├── api/notifications.ts
├── model/
│ ├── protocol.ts
│ ├── notification-runtime.ts
│ ├── notifications.store.ts
│ └── connection-view.ts
├── pages/NotificationsPage.vue
├── ui/NotificationBell.vue
└── index.ts
协议解析负责把 unknown 变成可接受的数据;HTTP 适配调用真实资源;运行器管理连接与同步;Pinia 把非敏感状态提供给页面。
flowchart TD
APP[app 认证装配] --> STORE[Pinia 状态适配]
STORE --> RUN[NotificationRuntime]
RUN --> API[HTTP 适配与协议校验]
RUN --> WS[WebSocket 端口]
RUN --> CLOCK[时间 网络 可见性 随机数]
STORE --> BELL[铃铛与全局提醒]
STORE --> PAGE[通知中心]
PAGE --> API
运行器通过依赖端口访问 socket、时间、网络和 API,测试时可以控制它们的行为。
这样不必在真实浏览器里等待十秒握手超时、十五秒心跳和三十秒退避,才能检查每个边界。
这也不是给每个函数制造接口。SocketPort 与 RuntimeDependencies 对应的都是实际会变化、需要隔离的能力。
5. 一个应用实例一条连接,连接跟随认证生命周期
如果工作台、铃铛、通知页都在 mounted 时创建连接,一次路由切换就可能叠出多条 Socket。
本项目由 app 启动层监听已经通过 /me 验证的员工身份:
const stopNotificationWatch = watch(
() => (session.authenticated ? session.identity?.id : null),
(id) => {
if (id) notices.start()
else notices.stop()
},
{ immediate: true, flush: 'sync' },
)
运行器 start() 发现已经 active 就直接返回;store 还检查是否已经监听当前 SessionBridge 的 AbortSignal。
路由切换不重新建立连接,退出、到期、改密或会话替换则同步清理。开发环境 HMR 也释放旧监听和运行器,避免热更新累加连接。
“唯一连接”的范围是单个应用实例。员工打开两个浏览器标签页或两台设备时,各自可以有连接,不应把这段代码描述为跨浏览器单连接选主。
6. 原生 WebSocket 怎样使用员工认证,又不把 Bearer 放进 URL
员工 HTTP 请求已有统一 Bearer 客户端。建立浏览器原生 WebSocket 时,使用 P6 提供的一次性票据。
sequenceDiagram
participant UI as 已认证浏览器
participant API as 通知HTTP接口
participant WS as 通知流
UI->>API: POST stream-tickets,员工Bearer
API->>API: 复验当前员工会话,签发短期一次性票据
API-->>UI: ticket、expiresAt、protocol
UI->>WS: 同源握手,业务子协议与ticket子协议
WS->>WS: 消费票据并复验身份与Origin
WS-->>UI: READY,正确业务协议
UI->>API: HTTP补查连续通知
实际 Socket 装配:
socket: (ticket) =>
new WebSocket(streamUrl(window.location.origin), [
ticket.protocol,
`ticket.${ticket.ticket}`,
]),
固定业务协议为 han-menu.notifications.v1。连接 URL 使用同源固定路径 /api/v1/notifications/stream,HTTPS 自动转 WSS。
票据只在建立连接的局部变量中使用,不进入 URL、sessionStorage、localStorage、Pinia 状态或日志。每次重连重新签发,不复用旧票据。
这减少业务代码中的凭证暴露,不代表浏览器网络诊断工具看不到握手协议内容。票据仍然是应受保护的短期凭证。
7. 签票成功、onopen 与 READY 是不同阶段
票据 HTTP 返回成功,只表示取得了建立连接的候选凭证。
onopen 说明底层 WebSocket 已打开,仍不等于项目业务协议已经准备好。
实际运行器在 onopen 不推进成功状态:
socket.onopen = () => {
/* 收到READY才视为可用,TCP建立不代表业务握手完成。 */
}
只有解析到正确 READY 才执行:
if (value.type === 'READY' && value.protocol === PROTOCOL) {
if (this.ready) return
clearTimeout(this.readyTimer)
this.ready = true
this.failures = 0
this.state.connection = 'connected'
this.emit()
this.heartbeat(epoch, socket)
void this.refresh()
}
这是消息处理器的分支摘录。未在十秒内等到 READY,就关闭当前连接并进入恢复流程。
连接状态还不能代替数据同步状态:即使收到 READY,历史积压也可能尚未补完。
8. 心跳与退避分别解决什么问题
心跳用于发现看起来连接着、实际已经不再通信的连接。
本项目收到 READY 后安排心跳,发送文本 ping,等待 JSON PONG。发送后八秒内没有响应,就关闭当前 Socket 并重新申请票据。
每次收到 PONG 后,再安排十五秒后的下一次 ping。这是当前应用协议,不是 JavaScript 直接调用 WebSocket 底层控制帧 API。
重连需要等待,而不是失败后立即循环
实际退避函数:
private retryDelay(error?: unknown) {
const backoff = Math.min(30000, 1000 * 2 ** Math.min(this.failures++, 5))
return Math.max(
backoff + Math.round(this.deps.random() * 500),
error instanceof ApiProblem ? error.retryAfter * 1000 : 0,
)
}
等待大约为 1、2、4、8、16、30 秒,再加不超过 500 毫秒的随机抖动;如果服务端 Retry-After 要求更久,就采用更长等待。
其中单位为毫秒,jitter 范围为零到五百。收到 READY 后重置连续失败计数。
网络 offline 时关闭连接并暂停重连,online 后重新连接并补查。不能在已知离线时仍高频签票。
WebSocket 关闭码 1008 等异常会进入 HTTP 复验流程,由真实 401 触发统一会话清理。浏览器连接错误本身不足以判断“员工一定已经被停用”。
9. 实时帧只触发补查,为什么反而更容易保证正确
一个实时事件的序号可能大于当前读取游标,但中间还存在尚未取得的记录。
如果收到第 103 条就直接 cursor = 103,第 101、102 条可能永远被跳过。
当前收到业务帧后的处理只有:
const notice = parseNotice(value)
if (notice.sequence > this.state.cursor) void this.refresh()
它没有把帧直接插入页面数组,也没有推进 cursor 或 receipt。
sequenceDiagram
participant WS as 实时流
participant R as 客户端运行器
participant HTTP as 持久化通知接口
WS-->>R: 提醒可能已有sequence=103
R->>HTTP: GET after=100,limit=50
HTTP-->>R: 连续记录101、102、103,nextCursor=103
R->>R: 整页验证后推进读取位置
WS-->>R: 重复sequence=103
R->>R: 已验证游标覆盖,无需重复展示
即使一条合法形状的实时帧声称序号 999,HTTP 实际只返回到 103,客户端读取位置也只能停在已验证的 103。
WebSocket 提高发现变化的及时性;持久化查询负责恢复事实。两者配合,不要求实时传输恰好一次且永不乱序。
10. 连续页校验不能只检查 nextCursor 是数字
HTTP 页面解析同时检查条目、游标和分页关系。
实际核心代码:
export function parsePage(value: unknown, after: number): FeedPage {
const data = record(value)
if (
!safeSequence(after) ||
!Array.isArray(data.items) ||
data.items.length > PAGE_SIZE ||
typeof data.hasMore !== 'boolean' ||
!safeSequence(data.nextCursor)
)
invalid()
const items = (data.items as unknown[]).map(parseNotice)
if (
items.some((item, index) => item.sequence !== after + index + 1) ||
new Set(items.map((item) => item.id)).size !== items.length ||
data.nextCursor !== (items.at(-1)?.sequence ?? after) ||
(data.hasMore && !items.length)
)
invalid()
return { after, items, nextCursor: data.nextCursor as number, hasMore: data.hasMore as boolean }
}
PAGE_SIZE 为 50。parseNotice 还会检查通知 UUID、订单 UUID、固定类型、正的安全整数序号和可解析时刻。
| 返回情况 | 处理 |
|---|---|
| after=10,返回 11、12,nextCursor=12 | 接受 |
| after=10,空页,nextCursor=10,hasMore=false | 接受 |
| 返回 11、13 | 拒绝整页,不能跳号 |
| 两条记录重复 UUID | 拒绝整页 |
| 最后一条 12,nextCursor=999 | 拒绝整页 |
| 空页却 hasMore=true | 拒绝,避免无进展循环 |
本项目 P6 的协议支持全局连续序列,前端据此要求后一条正好加一。这个检查不能直接照搬到允许删除、按角色过滤或序号天然稀疏的其他系统。
JavaScript 端还要求 Number.isSafeInteger,避免超出安全整数范围后失去可靠比较。若将来协议规模超过这个范围,需要演进序号表示,而不是继续用 number 猜测顺序。
11. 后台怎样逐页补齐,又不缓存全部历史
运行器保存的是已验证读取位置和最后提示,没有把所有历史通知堆进 Pinia。
同步循环核心片段:
while (more && this.current(epoch)) {
const page = await this.deps.api.page(this.state.cursor, this.controller.signal)
if (!this.current(epoch)) return
this.state.cursor = Math.max(this.state.cursor, page.nextCursor)
more = page.hasMore
latest = page.items.at(-1) || latest
this.emit()
}
这里的 api.page 已经经过协议解析。只有完整成功页的 nextCursor 才能推进拉取位置。
如果 1—50 成功,51—100 失败,下次从最后验证的 50 继续,不把游标退回零,也不跳到服务器其他查询给出的“全局最新”。
多人同时推进阅读进度时,读取起点也可以提升到服务器已确认的本人已读下界;这不是从一条实时帧跳号,而是接受服务端已经确认的阅读事实。
多个补查触发共用一轮同步
READY、实时帧、页面显示和定时器可能同时要求 refresh。
if (this.syncPromise) {
this.resync = true
return this.syncPromise
}
当前同步未完成时复用 Promise,同时标记 resync,让本轮之后再检查一次,减少遗漏发生在分页边界附近的新变化。
这里解决的是同一运行器的重复同步,不是把 HTTP 查询变成数据库事务,也不代表后端不再需要可靠分页协议。
12. HTTP 补查始终存在,WebSocket 失败不会让通知消失
全局后台同步在页面可见时每十五秒触发,隐藏时每六十秒触发,恢复可见立即核对。
这与 PC-3 订单详情隐藏时停止轮询不同。通知是应用级恢复能力,需要在降低后台频率的同时继续保留补查路径。
离线时 refresh 不发请求,等待网络恢复。浏览器对后台计时器的调度可能延迟,因此这些时间是计划频率,不是严格到达保证。
即使 WebSocket 因 Origin 或网络问题持续失败,HTTP 仍能获取通知、同步已读和提交本页确认。
连接展示按多种状态共同决定:
export function connectionView(state: RuntimeState) {
if (state.connection === 'stopped') return { label: '通知已断开', color: 'default' }
if (state.connection === 'offline') return { label: '网络已断开', color: 'warning' }
if (state.error) return { label: '通知同步暂不可用', color: 'warning' }
if (state.syncing || !state.caughtUp) return { label: '正在同步通知', color: 'processing' }
if (state.connection === 'connected') return { label: '实时通知已连接', color: 'success' }
return { label: '定期补查中', color: 'warning' }
}
同步失败不会被“Socket 还连着”的绿色状态遮住,也不会伪装成没有新消息。
13. 两种进度是整套阅读恢复的核心
运行器同时维护 cursor 与 receipt:
export interface Receipt {
sequence: number
version: number
}
| 字段 | 来源与含义 |
|---|---|
| cursor | 本实例已验证的读取位置,必要时接受服务器已读下界 |
| receipt.sequence | 服务端保存的当前员工阅读进度 |
| receipt.version | 对阅读进度执行并发修改的版本 |
本实例确认一页时,要满足这样的关系:
第一页必须从当前已读下界之后开始,而且确认的末尾必须已经被后台验证。
不能用“已拉取到哪”直接决定“员工已读到哪”。后台主动读取是一项技术操作,员工确认是一项用户行为。
14. 五十五条积压,为什么第一次只能确认五十条
假设服务器已读为 0,积压通知共有 55 条。
后台启动后会把读取游标补到 55,但通知页最多展示连续 50 条。
| 时刻 | 后台 cursor | 已读 sequence | 页面内容 |
|---|---|---|---|
| 初始读取完成 | 55 | 0 | 1—50 |
| 员工确认本页 | 55 | 50 | 本次只确认 1—50 |
| 读取下一页 | 55 | 50 | 51—55 |
| 再次明确确认 | 55 | 55 | 后续读取为空 |
第一次 PUT 的 sequence 必须是 page.nextCursor,即 50,而不是后台 cursor 的 55。
sequenceDiagram
participant B as 后台运行器
participant U as 通知页面
participant S as 服务端
B->>S: 从0逐页补查
S-->>B: 已验证到55
U->>S: GET after=0,limit=50
S-->>U: 1到50,nextCursor=50
U->>U: 员工确认当前50条
U->>S: PUT sequence=50,当前receipt.version
S-->>U: 新阅读进度为50
U->>S: GET after=50
S-->>U: 51到55,等待下一次确认
如果按钮叫“确认本页”,实际却提交后台最新游标,用户就会在没有看到后五条时把它们标为已读。
同理,点某一条通知打开订单不会执行跳读。当前协议保存连续前缀进度,没有逐条已读集合,不能用一次单条点击悄悄推进整个前缀。
15. 确认范围需要一份稳定的页面快照
后台补查和通知页读取分别保存自己的状态。新通知到达后,页面提示可以刷新,不把新条目自动塞进当前确认范围。
实际页面先捕获 displayed:
const displayed = page.value,
session = sessionBridge.snapshot().generation,
epoch = generation
confirming.value = true
确认框显示当前条数,并明确新的后续消息不会一起确认。用户同意后,检查页面代数和会话代数,再提交 displayed。
运行器还会再次检查这份页面是否仍能确认:
const receipt = this.state.receipt
if (
!this.active ||
this.state.pendingAck ||
!receipt ||
!page.items.length ||
page.after !== receipt.sequence ||
page.nextCursor > this.state.cursor
)
throw new ApiProblem(409, '阅读进度或页面已变化,请重新读取本页')
检查通过后,PUT 使用当前 receipt.version 和当前页的末尾序号。
确认与提交期间禁止翻页、切换视图和跳转订单。卸载时销毁确认框;会话失效时允许安全退出,旧弹窗不能给新账号发送确认。
16. 全部记录为什么只有上一页与下一页,没有总页数
后端给的是游标分页,没有提供任意页随机访问和完整总量。
历史模式从 after=0 开始,下一页使用上一完整页面的 nextCursor;返回上一页则使用本次访问保存的游标栈。
async function nextHistory() {
if (!page.value?.hasMore || loading.value || busy.value || error.value) return
historyBack.value.push(historyAfter.value)
historyAfter.value = page.value.nextCursor
await load()
}
页面仅保存当前内容与游标栈,不为每次翻页长期保存五十条历史对象。
这个模式没有已读确认按钮。它服务于查阅历史,不把查看旧记录混成推进当前阅读进度的命令。
没有总量就不虚构“第 3 页,共 25 页”。铃铛也只显示待阅读提示点,不根据目前已验证的差值冒充全店未读总数。
17. 多设备读进度要按版本合并,不能最后返回者覆盖
假设后台 GET 开始时读到 (sequence=50, version=3),响应还在路上;员工此时 PUT 成功,变成 (100,4)。
旧 GET 最后返回,如果直接赋值,就会把已读倒退到 50。
实际合并方法:
private mergeReceipt(value: Receipt) {
const previous = this.state.receipt
if (previous && value.version < previous.version) return
if (
previous &&
(value.sequence < previous.sequence ||
(value.version === previous.version && value.sequence !== previous.sequence))
)
throw new ApiProblem(502, '阅读进度不一致,请重新同步')
this.state.receipt = value
this.state.cursor = Math.max(this.state.cursor, value.sequence)
}
它不是分别对 version 和 sequence 取最大值,而是把服务器返回的二者作为一份有关联的事实检查。
- 更旧版本忽略。
- 相同版本却不同序号,视为不一致。
- 不允许新进度序号倒退。
- 接受有效新进度后,读取位置至少达到服务器确认的下界。
否则两个来源的字段分别取最大值,可能拼出一份服务器从未返回过的 receipt。
18. 多设备 409 后为什么不能自动再 PUT
设备 A 和 B 最初都显示从 0 开始的一页,使用阅读版本 0。
设备 B 先确认,推进服务端阅读进度;设备 A 再提交旧版本,收到 409。
正确恢复是重新取得服务器 receipt,保留当前页面并提示重读,让员工重新检查后明确确认。
sequenceDiagram
participant A as 设备A
participant B as 设备B
participant S as 阅读进度服务
A->>S: GET receipt
S-->>A: sequence=0,version=0
B->>S: PUT 自己确认的连续页,version=0
S-->>B: 保存新进度
A->>S: PUT 旧页末尾,version=0
S-->>A: 409
A->>S: GET 当前receipt
S-->>A: 返回设备B已经推进的事实
A->>A: 显示页面已过期,等待显式重读
自动重试如果顺便换成新页末尾,就会把用户没有展示和确认的范围也推进。即使只是换版本重发旧请求,也跳过了用户重新判断冲突的步骤。
页面用 page.after !== receipt.sequence 检测当前待阅读页是否过期。另一设备改变进度后,显示提示,不静默替换用户正在看的内容。
19. PUT 响应丢失时,同样只恢复事实,不追加新的确认
阅读确认可能已经提交成功,只是返回途中断网。
运行器的 catch 分支先 refresh,然后把原错误继续交给页面:
} catch (error) {
if (this.current(epoch)) {
await this.refresh()
throw error
}
}
页面设置 outcomeUnknown,要求显式重新读取。后台可能已经同步到了新 receipt,但不会因此自动发送第二次 PUT。
这与 PC-3 接单响应丢失的原则相同:命令结果未知时先核对状态,不能把网络错误当作重新执行用户意图的授权。
实际测试把这个竞争直接写出来
it('确认结果已保存但响应丢失时先同步,不能再次PUT跳过未显示页', async () => {
const h = harness()
h.setMessages([notice(1)])
runtime.start()
await settle()
h.api.acknowledge.mockImplementationOnce(async () => {
h.setReceipt({ sequence: 1, version: 1 })
throw new ApiProblem(0, '网络失败')
})
await expect(runtime.acknowledge(await h.api.page(0))).rejects.toMatchObject({ status: 0 })
expect(h.state().receipt?.sequence).toBe(1)
expect(h.api.acknowledge).toHaveBeenCalledTimes(1)
})
测试中的 harness 提供可控 API 与 Socket。它先改变服务器模拟事实,再抛出网络错误,验证客户端重读了进度,却没有重复确认。
20. 冷启动积压不连续弹窗,新的提醒也要合并
员工刚登录时可能积压很多通知。如果每补一条就弹一次,页面会被历史提示淹没。
运行器第一次完整同步建立 baseline,只更新读取状态和铃铛。之后的新同步若有新记录,才更新 activity,并使订单列表与工作台查询失效。
提醒组件使用固定 key:
const key = 'merchant-order-notice'
后续活动合并更新同一张提醒卡片,而不是无限叠加。页面隐藏时不弹提醒,已验证的待阅读状态仍然保留。
铃铛判断也很克制:
const hasUnread = computed(
() => !!state.value.receipt && state.value.cursor > state.value.receipt.sequence,
)
提示点只证明“客户端已验证的记录中,有高于已读下界的内容”。首次同步尚未完成或失败时,还需要连接状态和错误信息解释当前不可知的部分。
后台不会长期缓存全部历史记录,因此这里也没有精确未读计数承诺。
21. 停止连接之前,先让旧异步任务失去资格
如果 stop 只调用 socket.close(),尚未完成的票据请求仍可能在稍后返回并创建新连接。
运行器先标记 inactive、推进 generation,再取消资源:
stop() {
this.active = false
this.generation++
this.controller.abort()
this.disposeSocket()
clearTimeout(this.pollTimer)
clearTimeout(this.reconnectTimer)
this.reconnectTimer = undefined
this.syncPromise = null
this.connecting = false
this.resync = false
this.state = emptyState()
this.emit()
}
所有异步结果交付前检查:
private current(epoch: number) {
return this.active && epoch === this.generation && !this.controller.signal.aborted
}
Socket 事件还检查当前对象是否仍是原 Socket。关闭旧连接时先移除它的事件回调,避免旧 onclose 触发新的重连。
store 负责移除 online、offline、visibilitychange 与会话 abort 监听;disposeSocket 清理 READY、ping、pong 计时器。
| 资源 | 清理目的 |
|---|---|
| Socket 与回调 | 防止旧连接再触发补查或重连 |
| HTTP AbortController | 取消旧账号的票据、读取与确认 |
| 运行器 generation | 拒绝无法及时取消的旧结果 |
| 补查与退避计时器 | 防止退出后继续请求 |
| 网络与可见性监听 | 防止后续浏览器事件唤醒旧实例 |
| 页面确认框 | 防止旧阅读意图进入后来账号 |
会话桥保证凭证撤销时同步停止。服务端仍会复验活动连接对应的员工会话,前后端一起限制已被撤销的会话继续使用通知能力。
22. 开发代理的 Host 改了,Origin 不会因此自动正确
Vite 已代理 HTTP 和 WebSocket,但 changeOrigin 改变代理 Host,不会把浏览器实际 Origin 自动换成后端来源。
后端需要允许实际前端开发地址,例如:
NOTIFICATION_ALLOWED_ORIGINS=http://127.0.0.1:5173 ./scripts/with-env.sh ./mvnw spring-boot:run
可以把精确值放在被忽略的根 .env。当前加载脚本中,同名环境文件设置优先,应确认没有被另一值覆盖。
http://localhost:5173 与 http://127.0.0.1:5173 不是同一个 Origin,访问方式与允许列表需要对应。
生产环境使用 HTTPS/WSS,同源反向代理保留 WebSocket 升级所需头,并按实际公开 Origin 配置后端。
不能为了连通性把 Bearer 放到 URL、伪造 Origin 或配置通配来源来绕过当前设计。Origin 不匹配时,页面应如实显示实时连接不可用,同时保留 HTTP 补查。
本阶段还修正票据接口的 OpenAPI:Authorization 原来被重复声明为必填普通参数,与全局 Bearer 安全定义重叠。修复只隐藏文档中的适配参数,运行时 RequestHeader、安全链和票据消费规则不变。
23. 测试应该故意制造重复、乱序、失联和迟到
通知运行器的依赖可以替换,因此单元测试能控制票据返回、Socket 帧、时间推进与服务端阅读事实。
| 场景 | 关键断言 |
|---|---|
| 连续两次 start | 只建立一个 Socket |
| 一百二十五条积压 | 连续读取多页,不自动确认,不弹历史活动 |
| 重复、乱序帧 | 只触发补查,实际游标来自 HTTP |
| 第五十五条已拉取 | 确认第一页仍只提交第五十条 |
| 多设备 409 | 同步 receipt,PUT 总次数不增加 |
| PUT 成功但响应丢失 | 重读结果,不继续确认后续页 |
| PONG 缺失 | 原连接被替换,重新签票 |
| 票据迟到 | 退出后不能新建旧账号连接 |
| 旧 GET 迟到 | 不能覆盖较新阅读进度 |
| 中间页失败 | 停在最后成功游标,从那里继续 |
| Retry-After | 签票失败仍补查,重连遵守等待 |
协议测试另外验证跳号、重复标识、不安全整数、未知事件、票据前缀和有效期。
坏帧和坏页不会被 stringify 到日志,也不会被解释成一个空通知列表。前端展示固定安全错误,停止不可靠的游标推进。
五十五条的实际断言
const page = await h.api.page(0)
await runtime.acknowledge(page)
expect(h.api.acknowledge).toHaveBeenCalledWith(50, 0, expect.any(AbortSignal))
expect(h.state().receipt?.sequence).toBe(50)
expect(h.state().cursor).toBe(55)
await expect(runtime.acknowledge(page)).rejects.toMatchObject({ status: 409 })
expect(h.api.acknowledge).toHaveBeenCalledTimes(1)
这是在运行器已补齐五十五条、receipt 初始为零的测试中的断言片段。旧页确认成功后,再次提交同一页也会被拒绝。
24. 真实 Socket 测试与真实领域链路都需要保留
浏览器验收启用真实服务端通知工作器,使用独立 _test 数据库、随机 schema 和精确测试 Origin。
一部分场景通过测试脚本向本次 schema 准备固定类型通知事实,再由真实工作器推送到真实 Socket。它们用于稳定制造积压与顺序竞争。
另一部分通过真实顾客催单接口完成整条链路:
flowchart LR
CUSTOMER[顾客催单 HTTP] --> DOMAIN[订单领域行为]
DOMAIN --> EVENT[持久化领域事件]
EVENT --> NOTICE[通知持久化]
NOTICE --> WORKER[真实投递工作器]
WORKER --> SOCKET[浏览器 Socket]
SOCKET --> FEED[HTTP 验证与页面提醒]
两类验证不能混为一谈:预置通知事实验证的是后半段推送恢复;真实顾客催单还验证业务行为到通知产生的连接。
浏览器覆盖跨路由复用、55 条积压、断网重连、两设备竞争、退出换账号、停用撤销、HTTP 降级、催单和窄屏操作。
脚本不连接支付宝,不提供模拟支付成功接口,也不写开发业务库。
25. 阶段结果与后续边界
2026-09-21 最终本地验收记录:155 项后端测试、58 项前端单元测试、34 项真实浏览器测试全部通过,无跳过。
相比 PC-3,增加十八项前端单元测试与九项浏览器测试,既有认证、经营资料、订单与后端测试全部保留。
验证入口仍是:
./scripts/verify.sh
格式、Checkstyle、ESLint、模块边界、OpenAPI 类型一致性、类型检查和生产构建通过。Java 文档参数修正后也执行了 spotless。
断网、退出和撤销测试中保留预期的 WebSocket 代理连接重置诊断,没有过滤错误来通过门禁。设计记录见 admin/design-qa-pc4.md;远程 CI 尚未执行。
本篇只是引用上述阶段结果,没有重新执行这些验收。
PC-4 没有新增业务运行依赖、数据库表或 Flyway 迁移。它把 P6 已有后端协议接成了可恢复的员工通知体验,没有提前开放管理员重投、投影维护或资金操作。
26. 从“能收到消息”到“不会替用户确认”
这套实现把几个不同责任落到了明确位置:服务器保存可补查事实,运行器验证连续页并恢复连接,页面保留稳定确认范围,员工自己推进阅读进度。
后续接入资金与运维页面时,仍应坚持同样的边界:事件可以提示变化,真实读取决定展示事实,用户命令需要明确范围与并发前置条件。
读者练习
练习一:乱序帧。 当前 cursor=100,先收到 103,再收到 101。直接保存 103 会有什么风险?当前实现通过哪份响应证明 101—103 已经完整取得?
练习二:已拉取与已读。 cursor=55、receipt.sequence=0、页面显示 1—50。第一次确认应该发送什么?后台同步结束能否触发同样的请求?
练习三:迟到 GET。 PUT 已把进度推进到 (100,4),GET 返回旧的 (50,3)。为什么不能分别对两个字段做任意拼装?
练习四:关闭码与身份。 Socket 因 1008 关闭,但 HTTP 仍然认证成功。页面应直接退出还是进入恢复?真正的 401 由谁处理?
练习五:退出后的票据。 stop 时票据请求还没完成。只关闭当前 Socket 为什么不够?active、generation 和 AbortSignal 各起什么作用?
练习六:连续序列假设。 如果未来通知按门店或员工过滤,序号会跳过不可见消息,parsePage 的连续性校验还能原样使用吗?协议需要先重新定义什么?
练习七:本页发生变化。 用户打开确认框时另一设备推进已读,或新通知继续到达,哪些变化只提示刷新,哪些变化必须阻止本次确认?
至此,PC-1 的会话基础、PC-2 的经营资料、PC-3 的订单作业与 PC-4 的通知恢复形成了一条管理端操作链路。后续资金、报表与运维仍需要各自明确的查询口径、权限和操作契约。
实现对照
- 阶段契约:
docs/PC4_CONTRACT.md,后端来源为docs/P6_CONTRACT.md。 - 协议与恢复:
admin/src/modules/notifications/model/protocol.ts、notification-runtime.ts。 - 会话装配:
admin/src/main.ts、admin/src/modules/notifications/model/notifications.store.ts。 - 页面与提醒:
admin/src/modules/notifications/pages/NotificationsPage.vue、ui/NotificationBell.vue。 - 单元测试:
admin/src/modules/notifications/model/notification-runtime.spec.ts。 - 真实浏览器验收:
admin/e2e/notifications.spec.ts。
系列:Han Menu 外卖系统实践 · 从第一篇开始