Parcourir la source

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

三页(lsy 支付订单 / 退款记录 / HAR 订单)的新增与回调更新自动刷新。
含六条用户裁定、SSE 选型论证(否决 WS/STOMP)、前后端设计、
测试策略、上线前置与验收标准、写入口审计清单(起点)。
alphaH il y a 1 semaine
Parent
commit
8632bf5566
1 fichiers modifiés avec 238 ajouts et 0 suppressions
  1. 238 0
      docs/superpowers/specs/2026-10-05-order-realtime-push-design.md

+ 238 - 0
docs/superpowers/specs/2026-10-05-order-realtime-push-design.md

@@ -0,0 +1,238 @@
+# 订单实时推送(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(改动集中在一处)