页面断线十秒,申请已经批准

申请人正看着一张 SUBMITTED 的采购单。审批员提交决定后,页面希望立刻显示 APPROVED;此时浏览器恰好断线。若客户端把最后收到的 WebSocket 消息当作状态账本,重新连上时永远停在旧状态。推送解决的是低延迟提示,不是状态持久性;权威值仍是服务器数据库中的申请状态、版本及受授权的查询。这个判断尤其关系到权限:一个能够连接到 /ws/requests/42 的用户,不自动有权查看 42 号申请。采购实验工程已有 ProcurementUseCases 与 JdbcRequestStore,没有认证 WebSocket 端点,也没有受保护的申请详情 GET;下述链路全部是选修设计,不能把 /api/health 的成功当作实时进度验收。

先修不是可忽略的目录说明。06–11 需要提供明确的 Web 请求及 DTO,24–26 需要建立受认证主体、对象授权和可信租户上下文。眼下已有 /api/lab/requests/{id}/approve 把 X-Lab-Tenant 直接当操作租户,在 JAVAEE_DEMO_MODE=true 时仅限隔离教学环境;它既不验证登录,也不证明订阅者属于该租户。安全实验只能在新受保护入口上进行,不能借该演示接口宣布“越权订阅被阻止”。

握手时校验还不够

握手前的 HTTP 401 挑战与升级后的 WebSocket 关闭码是两个观察点。握手状态 101 只说明协议升级,不说明某张申请的订阅已授权;订阅 42 与订阅 43 必须各自检查主体、租户和对象。路径中的业务 ID 可能进入反向代理访问日志,不能把令牌、完整 Cookie 或供应商机密放入 URL。匿名握手的失败可在 HTTP 层发生,登录后向连接发送未授权订阅请求则应返回受限错误并关闭或拒绝该次订阅,不能把普通 REST 的 403 响应码当成已建立连接后的消息协议。

已认证的 A 租户用户也可能猜测大量 B 租户申请号,以拒绝内容或耗时推断是否存在。查询必须在可信租户条件下执行,对“其他租户的申请”和“完全不存在”给相同的对外结果;内部审计记录脱敏主体、目标和拒绝原因码。浏览器注销后旧的 WebSocket 连接未必自动关闭,需由服务端主动注销/断连或在推送前重新检查受信权限。测试应分列登录时、订阅时、撤权后和重连时四个时间点,而非只看第一次握手。

Jakarta WebSocket 2.2 的端点经 HTTP 升级握手建立长连接,之后消息不再逐条经过普通 REST 资源方法的权限判断。容器能提供握手关联的主体,但业务必须将主体映射到受信租户、角色和对象,并明确会话持续时间内的权限变化如何生效。Origin 值有助于浏览器来源检查,非浏览器客户端可自造该头,不能代替认证;Cookie 参与握手时还要检查跨站 WebSocket 劫持风险。客户端传入的路径 ID、订阅消息里的租户字段、曾收到的事件 ID 都是不可信输入。

目标端点是 @ServerEndpoint("/ws/requests/{requestId}")。它的 @OnOpen 顺序应是:取得握手关联 principal;确认账户仍有效;用服务端受信映射得出租户与访问权限;按 requestId + tenant_id 查询这张申请;不满足就关闭连接,不发送存在性细节。下列代码只表达入口和拒绝点:subscriptionService 尚未实现,不应把一个 Session 塞入跨请求可变的普通字段后就声称线程安全。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package blog.javaee.web;

import jakarta.inject.Inject;
import jakarta.websocket.CloseReason;
import jakarta.websocket.OnOpen;
import jakarta.websocket.Session;
import jakarta.websocket.server.PathParam;
import jakarta.websocket.server.ServerEndpoint;
import java.io.IOException;

@ServerEndpoint("/ws/requests/{requestId}")
public class ApprovalSocket {
@Inject SubscriptionService subscriptionService;

@OnOpen
public void open(Session session, @PathParam("requestId") long requestId) throws IOException {
if (session.getUserPrincipal() == null ||
!subscriptionService.allow(session.getUserPrincipal().getName(), requestId)) {
session.close(new CloseReason(CloseReason.CloseCodes.VIOLATED_POLICY, "Subscription denied"));
return;
}
subscriptionService.register(session, requestId);
}
}

该类并非现有源码,缺少 SubscriptionService、注销清理和完整握手策略;代码不能独立编译。即使服务器在握手时拒绝匿名,消息的租户隔离也不能依赖客户端填的租户名称。订阅表的键至少应含已核验租户和申请 ID,注册/移除需要避免一个会话绑定到另一个租户的同号业务标识。浏览器登出、用户被撤销组织权限或申请被转移时,已有连接不会神奇消失:定义是否主动关闭连接、在每次发送前重新授权,或用很短的租约再认证。仅在 @OnOpen 校验一次不足以满足“权限变更立刻生效”的强要求。

提交、推送和客户端读模型的时间顺序

审批数据库事务先用条件 SQL 更新 SUBMITTED -> APPROVED 并增加版本;成功提交之后才可以把已提交的 {requestId, version, status} 推给授权订阅者。若在提交前发出“已批准”,事务后来回滚,浏览器会看到不存在的决定。若提交后只调用一次内存广播,进程在这两者之间崩溃,数据库有新状态而无人收到提示。这不是 WebSocket 标准要解决的可靠投递问题。需要较强可恢复性时,可在同一事务写出站事件,再由有界任务发布提示;即便如此仍可能重复推送,客户端需要以申请 ID 和单调递增的版本去重与判旧,不以消息计数推导批准次数。

客户端在首次打开和每次重连之后都调用受保护的申请详情查询:先读当前 {status, version},再只接受版本大于本地版本的推送,或建立“先订阅后查询再合并”的顺序,避免查询与订阅之间的空档。两者都须处理竞态:查询返回版本 8 时广播版本 9 已在路上,最终合并取最大有效版本;广播版本 9 先到、查询版本 8 后到则不能回退。若版本变更并非每次业务变化严格递增,必须调整事件序号设计,不凭服务器时间戳猜顺序。断线期间发生的多个状态转换可以只按权威查询恢复最终状态;若产品要求完整审批历史,必须另建持久、按权限过滤的事件列表 API。

慢消费者为何可能拖垮在线审批

申请详情版本来自审批 SQL 更新的同一条业务记录。若推送版本 9 已抵达,随后的详情查询却读到滞后的副本版本 8,客户端不能按响应到达顺序回退;需维持版本 9 并按系统规定的最大滞后窗口向权威查询源再核验。对象访问权被撤销则是例外:客户端应停止展示先前缓存的报价,不可借“版本不可回退”永久保存已失权数据。完整审批历史必须走有权限过滤的持久查询,不能期望 socket 自动补发所有丢失帧。

审批数据库事务与发送动作也不能混为一谈。若在事务提交前广播 9,回滚后页面会显示不存在的结果;若在事务提交后仅做一次内存广播,提交与广播之间宕机会丢提示。让审批线程等待所有客户端回执会使慢网络拖长事务和行锁。可由同事务 outbox 持久化通知任务,发送器在提交后尝试投递;重复提示按申请 ID 和版本合并。即使采用 outbox,浏览器仍须在重连时查权威状态,因为它并不是带持久消费游标的消息队列成员。

对每个会话无界排队,少量网络慢的客户端就能累积大量消息和内存;同步在审批请求线程向所有会话 sendText,又把网速变成审批提交延迟。异步发送也只是改变等待位置,不会自动提供背压或可靠持久化。设计须限制每连接待发条数和字节数、消息最大大小、发送超时及连接数;超过阈值可合并为“状态已变,请重新查询”的提示,然后关闭慢连接。清理 @OnClose、@OnError 的登记,防止已撤权会话和断开的 socket 留在索引里。广播执行器必须是容器受管的,不把请求线程的 principal、JTA 事务或 ThreadLocal 隐式搬到异步线程。

最小可复验矩阵需要三个合成账户和 A/B 两租户:A 的申请人订阅 A 的已提交单并接收一次提交后的版本提示;未登录者、B 的账户及同 A 租户但不拥有访问权的账户订阅该 ID 应拒绝且不泄露存在性。随后断网、审批提交、重连并查询详情,客户端最终显示数据库版本;用读速率远小于发送速率的客户端触发队列上限,证明其他审批和订阅没有被无界等待拖住。记录握手状态、脱敏主体/租户、数据库最终行、服务端待发队列指标及重连的查询返回。curl 的普通 HTTP 200 无法验证升级握手或重连顺序;应选有 WebSocket 支持的客户端和受控网络故障。端点、身份及受保护详情尚未实现,正常、越权、断连和慢消费者实验全部 NOT_RUN。

订阅容量不仅以会话数衡量:完整报价的大帧可能比数百条简短状态提示占用更多内存。只发送申请 ID、版本和可公开给该主体的状态字段,把明细留在受保护的详情查询;用“每连接最大待发字节 × 最大连接数”推导服务端缓存预算,超过预算则合并为“请重新查询”并关闭慢连接。浏览器的退避重连需要抖动,避免服务器恢复时所有用户同时发起握手。退出页面后应触发服务器订阅登记清理,重复连接不能无限增加旧会话索引。上述队列边界也必须在隔离环境下用延迟读取客户端和内存指标验证,不能由静态阈值断言已经受控。

还有一个容易漏掉的业务时序:审批员在 A 申请上先拒绝、后续业务流程又创建新的替代申请,旧 socket 不能因同一个展示“采购单号”就误收新申请提示。订阅键以数据库不可混淆的租户 ID 与申请 ID 为准;如果页面聚合多个申请,服务端逐个授权或查询获准的 ID 集合,而非“加入整个租户频道”后由前端过滤。消息中的主体相关字段应采用固定版本的 JSON 结构和最大尺寸,客户端遇到未知版本不能默默当作已批准,需回退到详情查询。金额精度不从客户端解析推送来决定,仍以数据库中的服务器核价为准。

为验证断线恢复,实验需分别控制审批提交前断开、提交后但广播前断开、广播已发但浏览器未处理就断开;三种条件最后的详情查询都应归并到同一数据库版本,同时记录是否出现重复提示或旧版覆盖。网络代理可能在客户端尚未获知时切断连接,服务器端会话清理也不是瞬时的;用心跳或有限空闲超时清除僵尸连接,并把“连接在线”与“消息已被人看到”分开统计。即便同一连接的帧传输保持顺序,不同数据库查询和异步任务仍可能在业务上乱序,因此版本检查不能省。

最后一次授权与状态变更之间可能有权限撤销:消息进入待发队列时订阅者有权,发送时已经无权。若保密规则要求撤权立即生效,应在发送前复核授权,失权时丢弃队列中的旧消息并清除该连接登记;不要用“生成消息时通过”代替发送时的许可。实验以管理员撤销关联、服务端消息已排队而网络暂时阻塞为输入,恢复网络后检查 B 的任何申请信息都没有发到被撤权会话。此处队列和撤权信号都还未实现,属于 NOT_RUN。

两道带答案的练习

练习一: 申请初始版本 12,审批提交版本 13;客户端先收到了推送 13,再收到稍早发起的详情查询结果 12。按照消息抵达顺序更新 UI,会发生什么?怎样修正,并怎样验证?

解: 最后呈现 12,错误地把已批准状态回退。客户端以同一申请的版本作单调合并,只用较新的状态覆盖旧值;若版本缺失则重新查权威状态,不以本地收到的序号代替数据库版本。验收应强制颠倒响应顺序并核对最终界面版本为 13,数据库也确为 13;本文未运行该交错。

练习二: 某订阅者握手时有 A 租户权限,一分钟后管理员撤销该授权,连接仍接收 A 的审批提示。只加 Origin 白名单可修好吗?

解: 不能;Origin 约束浏览器来源,不表示这个主体此刻可访问申请。定义权限撤销事件主动关连接,或在发布前重新查询授权、到期重新握手;同时控制慢消费者队列,防止旧会话持有无限缓存。用撤权前后同一连接重测,不应再收到含 A 的状态;验证结果仍 NOT_RUN。

版本与证据边界

基线是 JDK 21、Jakarta EE 11、Open Liberty 26.0.0.5。规范入口:Jakarta WebSocket 2.2、Jakarta Security 4.0、Jakarta EE 11 Platform;HTTP 升级及关闭语义按 RFC 6455 对照。规范给端点和协议契约,不保证实现自动做对象授权、跨重启消息补齐或慢消费者限流。仓库真实入口是 examples/javaee-enterprise/webapp/src/main/java/blog/javaee/web/LabRequestsResource.java 与 examples/javaee-enterprise/adapters-jdbc/src/main/java/blog/javaee/jdbc/JdbcRequestStore.java;它们不包含此 WebSocket 实现。本章只提供可检查设计,未生成客户端输出或部署证据。