系列:Han Menu 外卖系统实践 · 从第一篇开始

上一篇:第11篇 · 下一篇:第13篇

系列:Han Menu 外卖系统实践 · PC-4

面向读者:已经理解 PC-1 的会话代数、P6 的持久化通知与 PC-3 的订单作业,希望学习浏览器长连接、游标恢复、多设备并发和可测试异步状态机的开发者。

本篇依据 PC-4 提交 cc79576docs/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 要求更久,就采用更长等待。

delay_n = \max\left(\min(30000,1000\cdot 2^{\min(n,5)}) + jitter,\ retryAfter\cdot1000\right)

其中单位为毫秒,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 对阅读进度执行并发修改的版本

本实例确认一页时,要满足这样的关系:

page.after = receipt.sequence
page.nextCursor \le cursor

第一页必须从当前已读下界之后开始,而且确认的末尾必须已经被后台验证。

不能用“已拉取到哪”直接决定“员工已读到哪”。后台主动读取是一项技术操作,员工确认是一项用户行为。

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:5173http://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.tsnotification-runtime.ts
  • 会话装配:admin/src/main.tsadmin/src/modules/notifications/model/notifications.store.ts
  • 页面与提醒:admin/src/modules/notifications/pages/NotificationsPage.vueui/NotificationBell.vue
  • 单元测试:admin/src/modules/notifications/model/notification-runtime.spec.ts
  • 真实浏览器验收:admin/e2e/notifications.spec.ts

系列:Han Menu 外卖系统实践 · 从第一篇开始

上一篇:第11篇 · 下一篇:第13篇