2026-10-05-order-realtime-push-design.md 20 KB

订单实时推送(SSE 长连接)— 设计文档

日期:2026-10-05 状态:设计已逐段确认(四段评审通过);本文档待用户审阅后进入实现计划(writing-plans) 范围:lsy 支付订单页、lsy 退款记录页、HAR 订单页的实时刷新

背景与目标

三个订单类页面目前是"进页面拉一次、之后靠手动刷新":新订单落库、上游回调改状态、退款推进,页面上都要等用户自己点查询才看得到。本设计给它们加一条服务端 → 浏览器的信号通道:

  • 任何会让订单/退款行变样的写入(建单、支付回调、退款回调、对账/主动查询、过期关单)发生后,对应页面在 ≤2 秒内自己更新,用户零操作
  • 用户的四条硬约束:轻量、用户无感知、便于维护、够快够准

"用户无感知"的完整含义(用户逐条裁定):界面上零新增元素(没有连接状态点、没有"有更新"提示、没有 toast);用户正在操作(勾选/弹窗)时信号挂起,回到安全状态后静默补刷;断线静默重连,重连后静默补拉。

现状(全部实测,2026-10-05)

后端

  • 零 WebSocket/SSE 基础设施:java/pom.xml 无 websocket 依赖;全仓唯一提及是 JwtAuthFilter.java:206 的注释;无任何 SseEmitter 使用
  • JwtAuthFilter 是 @Component OncePerRequestFilter(:39-41),经 SecurityConfig.java:102 addFilterBefore 挂在 Security 链上;extractToken 已支持 ?token=(:206-210,注释写明为 WebSocket 场景留的)
  • 无 token 时 JwtAuthFilter 直接放行(:71-75),认证兜底在 Security 的 anyRequest().authenticated()(SecurityConfig.java:99;白名单 :98)
  • JWT 滑动过期:jwt.sliding-expire 默认 true(:48-49);JWT 过期但 Redis 会话存活时照常放行(:82-105);会话是否存在以 Redis 为准(:122 起),请求时顺带续期(:148)
  • 无 Spring 事件骨架(全仓 grep 无 ApplicationEvent/@EventListener/@TransactionalEventListener);无 @EnableAsync(HarCallbackService.java:46-48 注释确认,项目惯例是自建小线程池);@EnableScheduling 在 PaymentApplication.java:11
  • spring.task.scheduling.pool.size 默认 1 —— 支付对账等 @Scheduled 全跑在同一个调度线程上(LeshouyingHttpConfig.java:23 明确防过这个坑)
  • 订单/退款写入点分散:
    • pay_lsy_order 建单 insert 5 处:LeshouyingCashierController.java:180、LeshouyingCheckoutController.java:208、LeshouyingOpenController.java:89/116/141
    • pay_har_order 建单 insert 1 处:HarOrderService.java:417
    • pay_lsy_refund 唯一 insert 点:LsyRefundService.java:235(:231 注释自证全仓仅此一处)
    • 状态/金额回写收口:LsyOrderService.applyPayResult(:60,返回 boolean,9 个调用点)与 applyRefundResult(:163);另有 LsyCancelService 关单收敛、LsyReconcileScheduler.expireStaleOrders 绝对过期、har 回写腿

前端

  • src/utils/websocket.ts 是空壳注册表(setupWebSocket() 只查 token 打日志),全 src 零调用方;VITE_APP_WS_ENDPOINT(8081)属于 AI 聊天页(module_ai/chat/index.vue:78,92),与本通道无关、不可复用
  • request.ts 没有自动刷新 token 的路径:401/TOKEN_EXPIRED → redirectToLogin(:133-140);store 的 refreshToken()(user.store.ts:180)全仓零调用,是死代码
  • 无 EventSource 使用(仅 @vueuse/core 的类型声明里出现;@vueuse/core@13.5.0 已安装但本设计不需要)
  • 无测试基建:无 vitest/jest、无测试文件、无 test 脚本(只有 ts:check/lint,且 ts:check 有既有错误基线)
  • 三页现状:
    • 支付订单 collection/order/index.vue:详情抽屉(detailVisible:533)、跨页勾选(selectedRows:542)、批量退款两弹窗(batchRefundVisible:586、batchRefundResultVisible:587);loadData:877 会置 loading 遮罩;已有"抽屉开着同一单,操作后三块数据一起重拉"的先例(:989-991);onBeforeUnmount 有停轮询的注释(:846-850,keep-alive 下定时器不自停的教训)
    • 退款记录 collection/refund/index.vue:纯列表,无弹窗无勾选;loadData:116
    • HAR 订单 har/order/index.vue:创建弹窗(createVisible:492)、详情弹窗(detailVisible:496);已有"详情弹窗开着同一单则重拉"先例(:694,741)
  • 开发链路:前端 VITE_APP_BASE_API=/api/v1 经 Vite 代理到 127.0.0.1:8001;生产同源(相对路径),无 CORS 问题

设计决策(用户已确认)

  1. 触发范围 = 任何写入都推:建单、支付回调、退款回调、对账/主动查询、过期关单;发布点收口在服务层写入方法,不逐回调埋点
  2. 页面 = 三页都监听:支付订单、退款记录、HAR 订单
  3. 部署拓扑 = 单实例 ⇒ 进程内存广播,不做 Redis pub/sub(日后扩容再改广播器,改动集中在一处)
  4. 页面行为 = 自动刷 + 操作中降级:有勾选/弹窗时不刷,只挂起
  5. 断线 = 重连(退避)+ 重连补拉,不做轮询兜底
  6. 用户无感知:无任何连接状态 UI / 提示;挂起的信号回到安全态自动补刷
  7. 传输选型 = SSE(用户授权由我方推荐后确认)

选型:SSE(否决 WebSocket 与 STOMP)

推荐 SSE 的四条依据(均为实测):

  1. 鉴权零新代码:SSE 端点是普通 @GetMapping,请求走与所有 API 完全同一条 Security 过滤链(JwtAuthFilter 在链内且有 ?token= 现成支持)。WS 握手需要另配 Security 白名单 + 处理"会话活过请求"的 principal 传递,等于再添一条鉴权面——本仓库 2026-10-05 刚实测出免登录三件套裸奔(见记忆 auto-login-endpoints-unauthenticated),新通道必须复用既有鉴权路径而不是绕开它
  2. 后端零新依赖:SseEmitter 是 spring-web 自带,pom.xml 一个字母不加
  3. 运维面可控且在应用侧:SSE 经 nginx 的缓冲问题,应用回 X-Accel-Buffering: no 即可关闭,配合 20s 心跳对付 proxy_read_timeout(默认 60s);WS 必须改 nginx 的 Upgrade/Connection/proxy_http_version——改不了、也看不到(部署凭据文件不可读)
  4. 开发环境零改动:SSE 是一条普通长响应,走现有 Vite 代理;WS 要在 vite 配置加 ws: true

诚实代价:SseEmitter 超时必须显式设置;一条 SSE 占浏览器对同源 HTTP/1.1 六连接之一(开发环境两三个标签页无碍);?token= 会进反代访问日志(WS 同样躲不掉——浏览器不能给这两种握手带自定义头),短票据加固列为后续可选项,本轮不做。

被否方案:STOMP(前端 +1 依赖、鉴权仍自写、为单一信号引一套消息协议);原生 WebSocket(新建鉴权面与安全配置面,收益只有"未来可能双向"——现在没有这个需求)。

总体架构与数据流

后端(写入侧收口一处)                后端(推送侧)              浏览器(每标签页一条)
┌────────────────────────────┐  publish   ┌─────────────────┐   SSE    ┌──────────────────────────┐
│ applyPayResult/applyRefund │ ─────────▶ │ OrderEventHub   │ ───────▶ │ orderStream.ts(单例)    │
│ 建单 insert / 关单·过期腿    │  领域事件   │  租户桶 → emitters│  信号    │  三个页面订阅同一条流      │
│  (AFTER_COMMIT 才投递)      │            │  超管桶 = 全量    │          │  useLiveRefetch:挂起/补刷 │
└────────────────────────────┘            └─────────────────┘          └──────────────────────────┘

延迟路径全程进程内 + 一条长连接:写入提交 → after-commit 投递 → Hub 单线程执行器 → socket 写 → 页面重拉(毫秒级),人工可观测上限 ≤2s。

后端设计

新增一个包 module/payment/stream/,四个小件:

件 职责
OrderEventController GET /api/v1/payment/order-events?token=…。普通 controller:SecurityContextHolder 取 LoginUser(与现有 controller 同一写法),按登录人把 SseEmitter 挂进租户桶或超管桶
OrderEventHub 桶:Map<tenantId, Set<SseEmitter>> + 超管桶;自带单线程执行器,所有 send 走它
OrderEventPublisher 唯一调用入口:changed(channel, tenantId) 一行
OrderChangedRelay @TransactionalEventListener(phase = AFTER_COMMIT, fallbackExecution = true) → 转交 Hub

为什么用领域事件 + AFTER_COMMIT:写库腿两种上下文并存——OpenapiService 建单腿有 @Transactional(:63 起多处),而 lsy 回调/退款腿是"刻意不加事务"的单行条件更新(LsyRefundService.java:36、LsyMerchantService.java:432 均有"别顺手补上"的注释)。fallbackExecution=true 让两种上下文共用一套机制:有事务 ⇒ 提交后才推;无事务 ⇒ 立即推(autocommit 下行已可见)。消灭"信号先到、库还没提交、页面拉到旧数据"的竞态。

发布点(三类收口,每处一行调用)

  1. 建单 insert:6 处(lsy 5 + har 1,见"现状"清单)——insert 成功后 changed(...)
  2. 状态/金额回写:applyPayResult / applyRefundResult 返回 true 才发(幂等重推天然不响);har 对应回写方法同规则
  3. 其它改行腿:LsyCancelService 关单收敛、LsyReconcileScheduler.expireStaleOrders、LsyRefundService 退款单状态腿

收口原则:宁可多发不可漏发(重拉幂等;漏发才是 bug)。实现时按「三张表(pay_lsy_order/pay_lsy_refund/pay_har_order)的全部写入口」做一次 grep 审计(附录 A 是起点清单,以实现时 grep 证据为准)。

投递语义

  • tenantId 非空 → 该租户桶 + 超管桶;tenantId = null(归属不明的行只对超管可见)→ 只进超管桶
  • 非超管且 tenantId = null 的登录人 → 不进任何桶(保守选择;实现时对照 TenantInnerInterceptor 对 null 租户的实际语义确认一次)
  • 信号不含数据,只有通道名;页面收到后走既有列表接口重拉 ⇒ 租户隔离沿用既有查询链,新通道不复制权限口径
  • 页面 ↔ 通道:
页面 订阅通道 理由
支付订单 lsy_order + lsy_refund 退款在途标记是退款表的派生量,退款表一动可退按钮就要翻
退款记录 lsy_refund
HAR 订单 har_order

SSE 帧格式

retry: 3000                     ← 流开始处一次,浏览器自动重试间隔
event: changed
data: {"channel":"lsy_order"}

event: ping                      ← 每 20s
data: {}

心跳必须是有名事件而不是注释帧:SSE 注释帧(: ping)JS 根本看不见,前端的"沉默超时"检测要靠它。changed 与 ping 都走 Hub 单线程执行器。

连接与资源

  • 端点响应头带 X-Accel-Buffering: no(应用侧关掉 nginx 缓冲,无需运维改配置)
  • SseEmitter 超时显式设置(不吃容器默认;具体常量实现时以一次真实静置验证为准)
  • 单线程执行器不是可选项:SseEmitter 不允许并发 send;且心跳 tick 只往执行器投任务,绝不占用 @Scheduled 线程(调度池默认 size=1,支付对账在上面)
  • 清理:onCompletion/onTimeout/onError + 发送异常 → 摘桶;空桶即删
  • 服务端不做令牌/会话轮询(对第 2 段"过期即关流"措辞的修正):JWT 是滑动过期,exp 不是会话权威源(Redis 会话才是,JwtAuthFilter:82-105),按 exp 关流会反复掐断仍有效的长连接;而流不含数据,会话真死时下一次 HTTP 补拉会走既有 401 → 登录页,不需要新机制。关流条件仅剩:客户端主动 stop(登出)、连接错误、发送失败
  • 重启/部署:单实例重启会断所有流,客户端自愈(重连 + 补拉),无需运维动作

端点与权限

  • 不加入任何白名单;未登录请求由 Security anyRequest().authenticated() 拒绝(实现时加一条"无 token → 401"的测试)
  • 流不设页面权限点(服务三个页面且不含数据);能看到什么由服务端按租户/超管分桶决定,页面拉到什么仍由各列表接口的权限决定——新通道不新增数据可见性

前端设计

新增 src/utils/orderStream.ts(沿用 utils/ 既有布局)+ src/composables/useLiveRefetch.ts(src/composables/ 目录已存在),删除空壳 src/utils/websocket.ts(零调用方,留着是误导;git 历史保留)。

orderStream.ts — 模块级单例

  • 引用计数:第一个订阅者开连、最后一个退订关闭;登出(user.store.ts 的 resetAllState)强制 stop()
  • 连接:new EventSource(${VITE_APP_BASE_API}/payment/order-events?token=…);每次(重)连现取 Auth.getAccessToken()(多标签页里别处换过 token 也跟得上)
  • 分发:changed(channel) 按通道发给订阅者;ping 只刷看门狗时间戳;onopen —— 首次连接不补拉(页面挂载本来就拉过一次);之后每次(重)连派发一次 resync("重连补拉"的落点)
  • 看门狗:每 10s 检查,>50s 没收到 ping(哑连接)→ 主动重建;readyState === CLOSED(决定性失败)→ 指数退避重建(1s→30s 封顶);浏览器对 CONNECTING 态的自带重试不干预(retry: 3000 由服务端给)
  • 流永不自己触发跳登录:会话真死时下一次任何 HTTP 都会走既有 401 → redirectToLogin(request.ts:133-140),不为流另立行为
  • 可注入 EventSource 工厂(node 假件验证用)

useLiveRefetch({ channels, busy, refetch }) — 挂起/补刷状态机

  • 信号到达:busy 为真 ⇒ pending = true;否则 ⇒ 静默 refetch()
  • watch(busy):真 → 假且 pending ⇒ 立刻静默补刷(操作一结束数据自己就齐)
  • 信号 500ms 合并(批量退款回调会连发一串);resync 走同一条路
  • 组件 onBeforeUnmount 退订;keep-alive 下缓存页保持订阅(后台也静默刷,切回即最新)

三页接线

页面 busy(挂起条件) 静默刷新 详情联动
支付订单 勾选非空 ‖ 详情抽屉 ‖ 批量退款两弹窗 loadData 不置 loading 遮罩、不打断滚动、失败不弹错 抽屉开着 ⇒ 同单静默重拉(先例 index.vue:989-991)
退款记录 无(永远静默刷) 同上 —
HAR 订单 详情弹窗 ‖ 创建弹窗 同上 弹窗开着 ⇒ 同单静默重拉(先例 :694,741)

静默的两处共享层小改

  • 各页 loadData 加 silent 参数:跳过 loading 遮罩;401 行为不变
  • request.ts 加"静默请求"开关(约 6 行):后台刷新失败不弹 toast(否则服务重启瞬间页面会自己弹"服务器连接失败",违反无感知);401/TOKEN_EXPIRED 分支保持原样

测试策略

后端(JUnit 既有基建):

测什么 要点
Hub 路由 租户桶 + 超管桶各收到;tenantId=null 只进超管桶
租户隔离(变异) 变异体:路由忽略 tenantId 直接广播 → 隔离用例必须红(两向验证)
Hub 清理 发送失败/完成/超时 → 摘桶,不泄漏
事务二分 无事务立即投递;有事务提交后投递、回滚不投递(需要事务管理器 ⇒ 用库时按现有测试同待遇走隧道)
发布点收口 applyPayResult/applyRefundResult 返回 true ⇒ publish 恰一次(mock publisher);false ⇒ 零次
端点鉴权 无 token → 401;?token= → 200 + text/event-stream

控制器 6 处建单 insert 不逐点写单测(成本高收益低):由附录 A 的 grep 审计 + E2E 覆盖,漏发由重连补拉兜底。

前端(不引入测试框架——那是一个单独的决定):

  • 连接管理器与挂起状态机是纯逻辑(vue 的 ref/watch 在 node 里直接跑):node 脚本 + 假 EventSource 验 token 现取、(重)连补拉、看门狗、退避、引用计数、500ms 合并
  • E2E 是主量具:真实浏览器 + 真实后端,观察"页面自己在变";证据取浏览器网络面板(零交互下出现新的 /order/page 请求)+ 响应字节,不靠截图印象

上线前置与验收标准

上线前置:

  1. 反代静置验证(唯一可能需要动手的):应用已带 X-Accel-Buffering: no + 20s ping(< nginx 默认 proxy_read_timeout 60s);上线检查项 = 连上后静置 ≥2 分钟确认 ping 连续到达(浏览器网络面板)。不过 → 才需要运维加 proxy_buffering off。开发环境先用 Vite 代理跑通一条最小链路,把这类问题最早暴露
  2. 部署物:常规重打 jar + dist
  3. 重启自愈:客户端重连 + 补拉,无运维动作

验收标准(可测句):

  1. 真实建单/回调改单 → 对应页面 ≤2s 自己变,全程零交互
  2. 隔离:tenant 27 的单变化时 q 的页面纹丝不动;正控:tenant 2 的单变化 → q 页变;(变异体必须让这条红)
  3. 挂起:勾选状态下信号到达不打断勾选;勾选清空的一瞬列表自动补成最新
  4. 自愈:重启后端 → 页面无提示自动恢复,断线窗口里的变化在重连后补齐
  5. 界面上零新增元素(无状态点、无提示、无 toast)

实现前必须先验证的小项(诚实清单)

  • SSE 过 Vite 代理(本地一条最小链路)
  • Spring Security 在 async 二次派发下的行为(一次真实请求)
  • SseEmitter 超时常量语义(静置验证)
  • 非超管 + tenantId=null 在 TenantInnerInterceptor 下的真实可见性
  • 反代(生产)具体是什么、是否缓冲

明确不做(YAGNI)

轮询兜底、连接状态 UI、推送数据载荷、双向通道、Redis pub/sub(单实例)、短票据鉴权加固、首页统计联动、动 AI 聊天页的 WS、重构批量退款既有轮询、引入前端测试框架。

附录 A:写入口审计清单(起点,实现时以 grep 为准)

对象 已知写入口 状态
pay_lsy_order insert LeshouyingCashierController:180、LeshouyingCheckoutController:208、LeshouyingOpenController:89/116/141 待埋
pay_lsy_order update LsyOrderService.applyPayResult:60、LsyCancelService(关单收敛)、LsyReconcileScheduler.expireStaleOrders、LsyOrderService.createQrOrder 内 code_url patch 待埋/待审计
pay_lsy_refund insert/update LsyRefundService:235(唯一 insert)、LsyRefundService 状态更新腿 待埋/待审计
pay_har_order insert HarOrderService:417 待埋
pay_har_order update har 回写腿(notify/查询/退款) 待审计

审计方法:对三张表分别 grep 全仓 insert/update/updateById 调用点,逐条对到发布调用;本清单自身会有失真,实现时以当场 grep 为准。

附录 B:已知代价(接受,不修)

  • ?token= 进反代访问日志(浏览器无法给 SSE/WS 握手带自定义头,两种方案同样存在)
  • 一条 SSE 占用浏览器对同源 HTTP/1.1 六连接之一(生产若为 HTTP/2 则无此限;开发环境可接受)
  • 单实例内存广播的扩展边界:日后横向扩容需要把广播器改为 Redis pub/sub(改动集中在一处)