日期:2026-10-05 状态:设计已逐段确认(四段评审通过);本文档待用户审阅后进入实现计划(writing-plans) 范围:lsy 支付订单页、lsy 退款记录页、HAR 订单页的实时刷新
三个订单类页面目前是"进页面拉一次、之后靠手动刷新":新订单落库、上游回调改状态、退款推进,页面上都要等用户自己点查询才看得到。本设计给它们加一条服务端 → 浏览器的信号通道:
"用户无感知"的完整含义(用户逐条裁定):界面上零新增元素(没有连接状态点、没有"有更新"提示、没有 toast);用户正在操作(勾选/弹窗)时信号挂起,回到安全状态后静默补刷;断线静默重连,重连后静默补拉。
后端
java/pom.xml 无 websocket 依赖;全仓唯一提及是 JwtAuthFilter.java:206 的注释;无任何 SseEmitter 使用JwtAuthFilter 是 @Component OncePerRequestFilter(:39-41),经 SecurityConfig.java:102 addFilterBefore 挂在 Security 链上;extractToken 已支持 ?token=(:206-210,注释写明为 WebSocket 场景留的):71-75),认证兜底在 Security 的 anyRequest().authenticated()(SecurityConfig.java:99;白名单 :98)jwt.sliding-expire 默认 true(:48-49);JWT 过期但 Redis 会话存活时照常放行(:82-105);会话是否存在以 Redis 为准(:122 起),请求时顺带续期(:148)ApplicationEvent/@EventListener/@TransactionalEventListener);无 @EnableAsync(HarCallbackService.java:46-48 注释确认,项目惯例是自建小线程池);@EnableScheduling 在 PaymentApplication.java:11spring.task.scheduling.pool.size 默认 1 —— 支付对账等 @Scheduled 全跑在同一个调度线程上(LeshouyingHttpConfig.java:23 明确防过这个坑)pay_lsy_order 建单 insert 5 处:LeshouyingCashierController.java:180、LeshouyingCheckoutController.java:208、LeshouyingOpenController.java:89/116/141pay_har_order 建单 insert 1 处:HarOrderService.java:417pay_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 已安装但本设计不需要)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:116har/order/index.vue:创建弹窗(createVisible:492)、详情弹窗(detailVisible:496);已有"详情弹窗开着同一单则重拉"先例(:694,741)VITE_APP_BASE_API=/api/v1 经 Vite 代理到 127.0.0.1:8001;生产同源(相对路径),无 CORS 问题推荐 SSE 的四条依据(均为实测):
@GetMapping,请求走与所有 API 完全同一条 Security 过滤链(JwtAuthFilter 在链内且有 ?token= 现成支持)。WS 握手需要另配 Security 白名单 + 处理"会话活过请求"的 principal 传递,等于再添一条鉴权面——本仓库 2026-10-05 刚实测出免登录三件套裸奔(见记忆 auto-login-endpoints-unauthenticated),新通道必须复用既有鉴权路径而不是绕开它SseEmitter 是 spring-web 自带,pom.xml 一个字母不加X-Accel-Buffering: no 即可关闭,配合 20s 心跳对付 proxy_read_timeout(默认 60s);WS 必须改 nginx 的 Upgrade/Connection/proxy_http_version——改不了、也看不到(部署凭据文件不可读)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 下行已可见)。消灭"信号先到、库还没提交、页面拉到旧数据"的竞态。
changed(...)applyPayResult / applyRefundResult 返回 true 才发(幂等重推天然不响);har 对应回写方法同规则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 |
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 + 发送异常 → 摘桶;空桶即删exp 不是会话权威源(Redis 会话才是,JwtAuthFilter:82-105),按 exp 关流会反复掐断仍有效的长连接;而流不含数据,会话真死时下一次 HTTP 补拉会走既有 401 → 登录页,不需要新机制。关流条件仅剩:客户端主动 stop(登出)、连接错误、发送失败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("重连补拉"的落点)readyState === CLOSED(决定性失败)→ 指数退避重建(1s→30s 封顶);浏览器对 CONNECTING 态的自带重试不干预(retry: 3000 由服务端给)redirectToLogin(request.ts:133-140),不为流另立行为useLiveRefetch({ channels, busy, refetch }) — 挂起/补刷状态机busy 为真 ⇒ pending = true;否则 ⇒ 静默 refetch()watch(busy):真 → 假且 pending ⇒ 立刻静默补刷(操作一结束数据自己就齐)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 覆盖,漏发由重连补拉兜底。
前端(不引入测试框架——那是一个单独的决定):
/order/page 请求)+ 响应字节,不靠截图印象上线前置:
X-Accel-Buffering: no + 20s ping(< nginx 默认 proxy_read_timeout 60s);上线检查项 = 连上后静置 ≥2 分钟确认 ping 连续到达(浏览器网络面板)。不过 → 才需要运维加 proxy_buffering off。开发环境先用 Vite 代理跑通一条最小链路,把这类问题最早暴露验收标准(可测句):
SseEmitter 超时常量语义(静置验证)tenantId=null 在 TenantInnerInterceptor 下的真实可见性轮询兜底、连接状态 UI、推送数据载荷、双向通道、Redis pub/sub(单实例)、短票据鉴权加固、首页统计联动、动 AI 聊天页的 WS、重构批量退款既有轮询、引入前端测试框架。
| 对象 | 已知写入口 | 状态 |
|---|---|---|
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 为准。
?token= 进反代访问日志(浏览器无法给 SSE/WS 握手带自定义头,两种方案同样存在)