Explorar o código

docs(订单): 实时推送实现计划 —— 14 任务(SSE 长连接)

按已批 spec docs/superpowers/specs/2026-10-05-order-realtime-push-design.md 展开:
后端 Hub/事件三件套/SSE 端点 → 三张表全量发布点(含写入口 grep 审计)→
前端 orderStream + useLiveRefetch(各带 node 执行级验证脚本)→ 三页接线 → E2E 与构建。

计划头列了 4 条与 spec 的偏差(详情不挂起 / 不写 @SpringBootTest / 无 token 记 403 /
类名与端点统一挂 stream/),已获用户确认。
alphaH hai 1 semana
pai
achega
6bc198e54c
Modificáronse 1 ficheiros con 3165 adicións e 0 borrados
  1. 3165 0
      .claude/plan/2026-10-05-order-realtime-push.md

+ 3165 - 0
.claude/plan/2026-10-05-order-realtime-push.md

@@ -0,0 +1,3165 @@
+# 订单实时推送(SSE 长连接)实现计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** lsy 支付订单页、lsy 退款记录页、HAR 订单页在服务端任何一次落库后 1 秒内自动更新,用户零感知(无状态点、无提示、无 toast、无新增按钮)。
+
+**Architecture:** 服务端把每条 `SseEmitter` 注册进进程内 Hub(超管桶 + 租户桶),写库方在**行真的改了**之后调 `OrderEventPublisher.changed(通道, 租户)`;事件经 `@TransactionalEventListener(AFTER_COMMIT, fallbackExecution=true)` 落到 Hub,由**唯一一条名为 `order-sse` 的守护线程**串行广播 `{"channel":"..."}` 帧。前端模块级单例维持一条 `EventSource`,收到帧后由 `useLiveRefetch` 走**既有列表接口**重拉 —— 租户隔离、分页、筛选、权限全部免费复用。
+
+**Tech Stack:** Spring Boot 3(`SseEmitter` 在 `spring-web` 内,零新依赖)、Spring `ApplicationEventPublisher`、Java 21(`record` 可用)、Vue 3 组合式 API、原生 `EventSource`、axios 拦截器、esbuild + node 假件脚本(本仓前端零测试框架,见 Task 8 的量具说明)。
+
+**Spec:** `docs/superpowers/specs/2026-10-05-order-realtime-push-design.md`(238 行,已提交 `8632bf55`)。本计划从它论证;不一致处以下方「与已批 spec 的偏差」为准。
+
+**任务总览**(14 个任务,每个任务一次 commit,B=后端 F=前端):
+
+| # | 任务 | 交付物 |
+|---|------|--------|
+| 1 | OrderEventHub | 进程内广播 + 心跳 + 单线程串行化 |
+| 2 | 事件三件套 | `OrderChangedEvent` / `OrderEventPublisher` / `OrderEventRelay` |
+| 3 | SSE 端点 | `GET /api/v1/payment/stream/orders` |
+| 4 | lsy 订单状态收口点 | `applyPayResult` / `applyRefundResult` 两个口 + 6 参构造波及 5 个测试类 |
+| 5 | lsy 订单表其余写点 | 8 处落点 / 10 行发布(建单 5 + 关单·撤销·过期 5)+ 审计回归 |
+| 6 | lsy 退款表写点 | 退款 insert + 3 处 update + 审计回归 |
+| 7 | har 订单表写点 | 6 处写点 + 审计回归 |
+| 8 | 前端 orderStream | EventSource 单例(退避/看门狗/重连补拉)+ node 验证脚本 |
+| 9 | 前端 useLiveRefetch | 挂起/补刷/合并 + node 验证脚本 |
+| 10 | 前端管线 | request.ts 静默开关 + 3 个 API 透传 + 登出停流 + 删死文件 |
+| 11 | 支付订单页接线 | 两通道 + busy + 详情联动 |
+| 12 | 退款记录页接线 | 一通道 + 静默重拉(无 busy,本页无操作态) |
+| 13 | HAR 订单页接线 | 一通道 + busy(创建/提交/退款确认框)+ 详情联动 |
+| 14 | 端到端验收 + 构建 | 真服务 E2E + jar/dist |
+
+## Global Constraints
+
+- **四个含凭据的文件绝不能碰**:`java/src/main/resources/application.yml`、`deploy.sh`、`docker-compose.yml`、`.claude/plan/ops-manual.md`(读取会撞权限闸)。**本计划不需要改任何配置项**——SSE 无开关、无新配置键。
+- **提交只走显式路径,永不 `git add -A`**;提交信息见各任务 Step,风格照本仓 `feat(订单实时): ...`。
+- **禁用 `sed -i`**(会压平 CRLF,仓库 `autocrlf=true`);改文件一律 Edit/Write。
+- **不得猜测或捏造**:计划里的行号是 2026-10-05 的读数;文件被动过之后以**锚点文本**为准,行号只作路标。
+- 后端测试命令:`cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test`(**mvn 用绝对路径**,`mvnw` 是坏的;**不要加 `-proc:none` / `-q`**;控制台 GBK;`mvn test` 需要库隧道 `localhost:5432` 在线)。单类:`-Dtest=类名`。`target/surefire-reports/` 跨运行累积 ⇒ **只读本次 stdout 的 `Tests run:` 汇总行**,别把旧报告当本次读数。
+- 前端检查:`cd frontend && pnpm ts:check` —— **基线恒 exit 2**(历史遗留类型错),只能「同机差分」用(先存基线输出,跑完 diff,看有没有**新增**的 `文件(行,列)`);更硬的出口是 `pnpm build`。任何变异验证都必须在 `frontend/` 目录里跑。
+- **新增依赖:零。** 前端 `package.json` 不动,后端 `pom.xml` 不动(`SseEmitter` 在 `spring-boot-starter-web` 里)。
+- **新字段一律加在字段块末尾**(构造器实参表尾追加)—— 现有 14 个测试类手工 `new` 这些类,只有尾追加才不用重排实参。
+- 断言用 **JUnit 5 Jupiter**(`org.junit.jupiter.api.Assertions`,全仓 79 个测试类用它);**本仓没有 AssertJ**(0 个类),不要写 `assertThat`。
+- 版本:Spring Boot 3.x(`jakarta.*` 注解,如 `jakarta.annotation.PostConstruct`)、Java 21。
+
+## 与已批 spec 的偏差(4 条,需你审计划时确认)
+
+1. **详情抽屉/详情弹窗不挂起。** spec 的 busy 表把「详情抽屉」「详情弹窗」列入挂起,但同一节又承诺「详情实时联动」—— 两者互斥。本计划取**详情实时、只有勾选态与操作弹窗(批量退款、退款确认、创建表单)挂起**。理由:主用例就是「收银台盯着一个未付单等它翻 SUCCESS」,挂起详情会砍掉这个用例。改动只影响 spec 表格一行。
+2. **不写 Spring 容器级测试(spec 的「事务二分」改形态)。** 全仓 81 个测试类**无一带 `@SpringBootTest`**(现状读数),拉一个起来会撞 `leshouying.enabled` 启动闸 + 库隧道依赖。拆成两半验:「提交后才投递」= relay 的**委托单测 + 注解断言**(Task 2 把 `AFTER_COMMIT`/`fallbackExecution` 钉在测试里)+ **真有事务的开放 API 建单腿 E2E**(Task 14:建单后 1 秒内页面出现新行;若信号早于提交,重拉看不到新行 ⇒ 用例必红)。
+3. **未登录状态码:spec 写「无 token → 401」,本计划按 403 写** —— 依据是源码:`SecurityConfig` 没配 `entryPoint` ⇒ 匿名被拒时走 Spring Security 的默认 `Http403ForbiddenEntryPoint`(403);而 401 是 `JwtAuthFilter` 自己抛的(**坏 token** 才走那条)。Task 3 的测试按 403 写(它测的是 controller 在 SecurityContext 里取不到用户时的防御分支)。**真实链路上的 403/401 由 Task 14 Step 4 当场 curl 量**;量出来若不是这两个数,改的是那条断言与这句说明,**不是改代码去迎合文档**。
+4. **端点路径与类名统一挂到 `stream/` 命名空间**(spec 里是另一套名字):spec 写 `GET /api/v1/payment/order-events`、`OrderEventController`、`OrderChangedRelay`;本计划写 `GET /api/v1/payment/stream/orders`、`OrderStreamController`、`OrderEventRelay`。理由:实时相关的东西收在一个包/一个前缀下,与前端唯一那个 `src/utils/orderStream.ts` 一一对应,日后扩容(换 Redis 广播器)也在同一处。**计划里的名字是实施口径**;若你更认 spec 的原名,现在说 —— 要改的是计划里 13 处 URL + 3 个类名。同类差异还有一处:spec 写 `useLiveRefetch({ channels, busy, refetch })`(对象参数),本计划写 `useLiveRefetch(channels, refetch, options)`(位置参数,见 Task 9 的 `LiveRefetchOptions`),同样以计划为准。
+
+## Review Focus
+
+按 spec 措辞实现完之后,最可能让人踩坑的五件事(每条都配了测试,括号是责任任务):
+
+1. **同一条 emitter 被并发 send**(心跳与广播撞上)——`SseEmitter` 不允许多线程并发写,症状是偶发 `IllegalStateException`、帧交叠、连接莫名断。→ Task 1 `生产路径的每一帧都在order_sse线程上发出`(8 线程并发提交 + 线程名断言)。
+2. **信号早于事务提交**(开放 API 建单腿**有事务**)——页面收到信号、重拉,看不到那行新数据:比不推更糟。→ Task 2 注解断言 + 委托测试;Task 14 真建单 E2E。
+3. **登出后前端还攥着连接**(token 已清,SSE 反复被 403 挡回、无限重连)→ Task 8 node 脚本的 `stopOrderStream` 用例 + Task 10 的 `resetAllState` 接线。
+4. **静默刷新搅掉用户正在做的事**(勾选被清空、弹窗下的列表跳页、详情抽屉内容被清)→ Task 11/12/13 的 busy 接线 + Task 14 的挂起 E2E(勾选中 → 触发写入 → 列表不动;取消勾选 → 静默补刷)。
+5. **生产 nginx 缓冲吞掉 ping**(静置 2 分钟被 `proxy_read_timeout` 掐断,页面从此不再更新)→ Task 3 的 `X-Accel-Buffering: no` 响应头 + Task 14 的 ≥2 分钟静置用例(本机直连测不出缓冲,**这条只能部署后验**;E2E 先验 ping 帧本身按时到达)。
+
+## 文件结构
+
+**后端新增**(新包 `com.payment.platform.module.payment.stream`,4 个类 + 3 个测试):
+- `OrderEventHub.java` — 桶、心跳、单线程串行化(Task 1)
+- `OrderChangedEvent.java` / `OrderEventPublisher.java` / `OrderEventRelay.java`(Task 2)
+- `OrderStreamController.java`(Task 3)
+- `src/test/java/.../stream/OrderEventHubTest.java` / `OrderEventPublisherTest.java` / `OrderEventRelayTest.java` / `OrderStreamControllerTest.java`
+
+**后端修改**(只加字段 + 发行,不动既有逻辑):
+- Task 4:`leshouying/service/LsyOrderService.java`
+- Task 5:`leshouying/controller/LeshouyingCashierController.java`、`LeshouyingCheckoutController.java`、`controller/open/LeshouyingOpenController.java`、`scheduler/LsyOrderPollScheduler.java`、`scheduler/LsyReconcileScheduler.java`
+- Task 6:`leshouying/service/LsyRefundService.java`
+- Task 7:`har/service/HarOrderService.java`、`HarNotifyService.java`、`HarCallbackService.java`
+- 测试侧:Task 4 一次性修完 `LsyOrderService` 的 5 个手工构造点;Task 5 再修 6 个测试类里的构造点(**仅 `LsySchedulerTest` 一个类就有 14 处**:`new LsyOrderPollScheduler(` ×7 + `new LsyReconcileScheduler(` ×7)。两份清单分别在两个任务的 Files 里,行号以当场 `grep -n` 为准
+
+**前端**:
+- 新增 `src/utils/orderStream.ts`(工厂 + 单例,Task 8)、`src/composables/useLiveRefetch.ts`(Task 9)、`scripts/verify-order-stream.mjs`(Task 8)、`scripts/verify-live-refetch.mjs`(Task 9)
+- 修改 `src/utils/request.ts`、`src/api/module_payment/collection.ts`、`src/api/module_payment/har.ts`、`src/store/modules/user.store.ts`(Task 10)
+- 修改 `src/views/module_payment/collection/order/index.vue`(Task 11)、`collection/refund/index.vue`(Task 12)、`har/order/index.vue`(Task 13)
+- 删除 `src/utils/websocket.ts`(零引用死文件,Task 10,删前重验)
+
+---
+
+### Task 1: 后端 —— OrderEventHub(进程内广播 + 心跳 + 唯一一条 order-sse 线程)
+
+**Files:**
+- Create: `java/src/main/java/com/payment/platform/module/payment/stream/OrderEventHub.java`
+- Test: `java/src/test/java/com/payment/platform/module/payment/stream/OrderEventHubTest.java`
+
+**Interfaces:**
+- Consumes: 无(不依赖本仓任何既有代码)
+- Produces(后续所有后端任务都按这套签名调):
+  - `public SseEmitter register(Long tenantId, boolean superAdmin)`
+  - `public void submitBroadcast(String channel, Long tenantId)` — 生产路径(提交到 order-sse 线程)
+  - `public void broadcast(String channel, Long tenantId)` — 同步版(调用线程直接发,测试用)
+  - `protected boolean sendFrame(SseEmitter, String channel)` / `protected void sendRetry(SseEmitter)` / `protected boolean sendPing(SseEmitter)` — 测试覆写点
+  - 常量 `TIMEOUT_MILLIS = 1 小时`、`PING_INTERVAL_SECONDS = 20`、`RECONNECT_TIME_MILLIS = 3000`
+  - 包级访问器 `connectionCount()` / `tenantConnectionCount(long)` / `tenantBucketCount()`(**仅测试用**,生产代码不许调)
+
+- [ ] **Step 1: 写失败的测试**
+
+创建 `java/src/test/java/com/payment/platform/module/payment/stream/OrderEventHubTest.java`:
+
+```java
+package com.payment.platform.module.payment.stream;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Hub 的桶路由与线程模型。测试里**不碰真 emitter**(没有 HTTP 响应可写):
+ * RecordingHub 覆写全部 send 出口记账,SseEmitter 只当身份令牌用。
+ */
+class OrderEventHubTest {
+
+    private final RecordingHub hub = new RecordingHub();
+
+    @AfterEach
+    void tearDown() {
+        hub.shutdown();      // 每个用例都要收:Hub 构造时起了执行器与心跳,不收会攒线程
+    }
+
+    @Test
+    void broadcast_routesOnlyToTheSameTenant() {
+        SseEmitter t1 = hub.register(1L, false);
+        SseEmitter t2 = hub.register(2L, false);
+
+        hub.broadcast("lsy_order", 1L);
+
+        assertEquals(List.of("lsy_order"), hub.framesOf(t1));
+        assertTrue(hub.framesOf(t2).isEmpty(), "别的租户一帧都不该收到");
+    }
+
+    @Test
+    void broadcast_reachesSuperAdminForAnyTenant() {
+        SseEmitter admin = hub.register(null, true);
+
+        hub.broadcast("har_order", 2L);
+
+        assertEquals(List.of("har_order"), hub.framesOf(admin));
+    }
+
+    @Test
+    void register_superAdminWithTenantId_doesNotDoubleReceive() {
+        // 超管也可能带 tenantId(UserDetailsServiceImpl 把 sys_user.tenant_id 原样搬进 LoginUser)。
+        // 两条桶都收他 = 一条连接同帧收两次 = 页面同秒重拉两次。
+        SseEmitter admin = hub.register(2L, true);
+
+        hub.broadcast("lsy_order", 2L);
+
+        assertEquals(List.of("lsy_order"), hub.framesOf(admin));
+    }
+
+    @Test
+    void register_nonSuperAdminWithoutTenant_getsNothing() {
+        SseEmitter nobody = hub.register(null, false);
+
+        hub.broadcast("lsy_order", 1L);
+
+        assertTrue(hub.framesOf(nobody).isEmpty());
+        assertEquals(0, hub.connectionCount(), "无租户的非超管不进任何桶(保守:宁可少推)");
+    }
+
+    @Test
+    void broadcast_sendFailure_removesConnectionAndEmptyBucket() {
+        SseEmitter dead = hub.register(3L, false);
+        hub.failFor.put(dead, true);
+
+        hub.broadcast("lsy_refund", 3L);
+
+        assertEquals(0, hub.connectionCount(), "发不出去的连接要立刻摘掉");
+        assertEquals(0, hub.tenantBucketCount(), "空桶要删 key,否则每个登录过的租户永远留一个空 List");
+    }
+
+    @Test
+    void submitBroadcast_everyFrameGoesOutOnTheOrderSseThread() throws Exception {
+        SseEmitter t1 = hub.register(1L, false);
+        hub.expectFrames(8);
+        ExecutorService callers = Executors.newFixedThreadPool(8);
+        try {
+            for (int i = 0; i < 8; i++) {
+                callers.execute(() -> hub.submitBroadcast("lsy_order", 1L));
+            }
+            assertTrue(hub.awaitFrames(5, TimeUnit.SECONDS), "8 次提交应在 5 秒内出完 8 帧");
+        } finally {
+            callers.shutdownNow();
+        }
+        assertEquals(8, hub.framesOf(t1).size());
+        assertEquals(List.of("order-sse"), hub.frameThreads.stream().distinct().toList(),
+                "所有 send 必须落在同一条 order-sse 线程上 —— SseEmitter 禁并发 send,"
+                        + "这条红了说明有人在调用线程上直接发了");
+    }
+
+    /** 覆写全部 send 出口:只记账,不碰真 emitter。 */
+    static class RecordingHub extends OrderEventHub {
+        final Map<SseEmitter, List<String>> sent = new ConcurrentHashMap<>();
+        final Map<SseEmitter, Boolean> failFor = new ConcurrentHashMap<>();
+        final List<String> frameThreads = new CopyOnWriteArrayList<>();
+        private volatile CountDownLatch latch = new CountDownLatch(0);
+
+        void expectFrames(int n) {
+            latch = new CountDownLatch(n);
+        }
+
+        boolean awaitFrames(long timeout, TimeUnit unit) throws InterruptedException {
+            return latch.await(timeout, unit);
+        }
+
+        List<String> framesOf(SseEmitter emitter) {
+            return sent.getOrDefault(emitter, List.of());
+        }
+
+        @Override
+        protected boolean sendFrame(SseEmitter emitter, String channel) {
+            if (Boolean.TRUE.equals(failFor.get(emitter))) {
+                return false;
+            }
+            sent.computeIfAbsent(emitter, k -> new CopyOnWriteArrayList<>()).add(channel);
+            frameThreads.add(Thread.currentThread().getName());
+            latch.countDown();
+            return true;
+        }
+
+        @Override
+        protected void sendRetry(SseEmitter emitter) {
+            // 注册时的 retry 帧不记账:它不属于任何通道,计进去会打乱帧计数
+        }
+
+        @Override
+        protected boolean sendPing(SseEmitter emitter) {
+            return true;   // 心跳在单测里是噪声(20s 才来一次,用例早结束了)
+        }
+    }
+}
+```
+
+- [ ] **Step 2: 跑它,确认红**
+
+Run: `cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest=OrderEventHubTest`
+Expected: 编译失败 `cannot find symbol: class OrderEventHub`(Java 里新类没建就是「函数不存在」的等价红)。
+
+- [ ] **Step 3: 写实现**
+
+创建 `java/src/main/java/com/payment/platform/module/payment/stream/OrderEventHub.java`:
+
+```java
+package com.payment.platform.module.payment.stream;
+
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 订单实时信号的进程内广播器(部署形态是单实例,见 spec)。
+ *
+ * <p>桶模型:超管一桶 + 租户各一桶。超管**只进超管桶** —— 同一条连接收两份帧既是浪费、
+ * 又会让页面同一秒重拉两次。非超管且 tenantId 为 null:不进任何桶(保守,宁可少推)。
+ *
+ * <p>线程模型:对 emitter 的**每一次写**(注册时的 retry 帧、广播帧、心跳 ping)都提交到
+ * 同一条名为 {@code order-sse} 的守护线程上串行执行 —— {@code SseEmitter} 不允许多线程
+ * 并发 send。这条线程是自建的,**不是** Spring 的调度池:{@code spring.task.scheduling.pool.size}
+ * 默认 1、支付对账调度器就挂在上面,占用它会把对账挤停。
+ */
+@Slf4j
+@Component
+public class OrderEventHub {
+
+    /** emitter 超时:显式 1 小时。不赌 0L 的语义;到点自然断,前端重连 + 补拉接住。 */
+    public static final long TIMEOUT_MILLIS = TimeUnit.HOURS.toMillis(1);
+    /** 心跳间隔 20s:nginx proxy_read_timeout 默认 60s,留 3 倍余量。 */
+    public static final long PING_INTERVAL_SECONDS = 20;
+    /** 重连提示:浏览器断线后 3s 再来(首次连接不受影响)。 */
+    public static final long RECONNECT_TIME_MILLIS = 3000L;
+
+    private final List<SseEmitter> superAdmins = new CopyOnWriteArrayList<>();
+    private final Map<Long, List<SseEmitter>> byTenant = new ConcurrentHashMap<>();
+
+    private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(r -> {
+        Thread t = new Thread(r, "order-sse");
+        t.setDaemon(true);
+        return t;
+    });
+    private ScheduledFuture<?> heartbeat;
+
+    /**
+     * 注册一条连接。返回的 emitter 交给 Spring MVC 接管响应。
+     *
+     * @param tenantId   登录用户的租户;可为 null
+     * @param superAdmin is_superuser 开关(与 TenantInnerInterceptor 用的是同一个)
+     */
+    public SseEmitter register(Long tenantId, boolean superAdmin) {
+        SseEmitter emitter = new SseEmitter(TIMEOUT_MILLIS);
+        List<SseEmitter> bucket = superAdmin ? superAdmins
+                : (tenantId == null ? null : byTenant.computeIfAbsent(tenantId, k -> new CopyOnWriteArrayList<>()));
+        if (bucket == null) {
+            // 无租户的非超管:收不到任何信号,立刻正常结束,别让客户端挂满 1 小时
+            log.info("[订单SSE] 无租户的非超管登录,不建连接");
+            emitter.complete();
+            return emitter;
+        }
+        // ★ spec 的「实现前必须先验证的小项」里有一条:非超管 + tenantId=null 在
+        //   TenantInnerInterceptor 下到底看不看得见那些行。**这一条没有做**,如实记在这里:
+        //   它改不了本通道的行为 —— 不入桶就是不推,而这位用户取数一直走列表接口,
+        //   加通道前后完全一致(不回归、也不泄露)。哪天真要给他推,改的是上面这个分支 + 一条用例。
+        //   别把它写成「已验证」。
+        emitter.onCompletion(() -> remove(bucket, emitter));
+        emitter.onTimeout(() -> remove(bucket, emitter));
+        emitter.onError(e -> remove(bucket, emitter));
+        bucket.add(emitter);
+        // retry 帧在 emitter 初始化前 send 会进容器的 earlySendAttempts 队列、由 initialize 时补发;
+        // 仍排到同一条线程上,保持「所有写都在 order-sse」这条不变式
+        executor.execute(() -> sendRetry(emitter));
+        return emitter;
+    }
+
+    /** 同步广播:调用线程直接发。生产别用(见 {@link #submitBroadcast}),测试用。 */
+    public void broadcast(String channel, Long tenantId) {
+        deliver(superAdmins, channel);
+        if (tenantId != null) {
+            List<SseEmitter> bucket = byTenant.get(tenantId);
+            if (bucket != null) {
+                deliver(bucket, channel);
+            }
+        }
+    }
+
+    /** 生产路径:提交到 order-sse 单线程上执行,与心跳串行。 */
+    public void submitBroadcast(String channel, Long tenantId) {
+        executor.execute(() -> broadcast(channel, tenantId));
+    }
+
+    private void deliver(List<SseEmitter> bucket, String channel) {
+        for (SseEmitter emitter : bucket) {
+            if (!sendFrame(emitter, channel)) {
+                remove(bucket, emitter);
+            }
+        }
+    }
+
+    private void remove(List<SseEmitter> bucket, SseEmitter emitter) {
+        bucket.remove(emitter);
+        if (bucket != superAdmins && bucket.isEmpty()) {
+            // 空桶删 key:否则每个登录过的租户都留一个空 List 到进程结束
+            byTenant.values().remove(bucket);
+        }
+    }
+
+    /** 帧格式:有名事件 + 一个 JSON 对象。**不带数据** —— 页面拿信号去重拉它自己的列表接口。 */
+    protected boolean sendFrame(SseEmitter emitter, String channel) {
+        try {
+            emitter.send(SseEmitter.event().name("changed").data(Map.of("channel", channel)));
+            return true;
+        } catch (Exception e) {
+            log.debug("[订单SSE] 发送失败,摘除连接: channel={}, err={}", channel, e.getMessage());
+            return false;
+        }
+    }
+
+    protected void sendRetry(SseEmitter emitter) {
+        try {
+            emitter.send(SseEmitter.event().reconnectTime(RECONNECT_TIME_MILLIS));
+        } catch (Exception e) {
+            // 注册瞬间客户端可能已经走了:忽略,onError/onCompletion 会清
+            log.debug("[订单SSE] 重连提示发送失败: {}", e.getMessage());
+        }
+    }
+
+    /** 心跳:有名事件 ping(注释帧 JS 看不见,前端看门狗就废了)。 */
+    protected boolean sendPing(SseEmitter emitter) {
+        try {
+            emitter.send(SseEmitter.event().name("ping").data("{}"));
+            return true;
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
+    @PostConstruct
+    void startHeartbeat() {
+        heartbeat = executor.scheduleAtFixedRate(this::pingAll,
+                PING_INTERVAL_SECONDS, PING_INTERVAL_SECONDS, TimeUnit.SECONDS);
+        log.info("[订单SSE] 广播器就绪(单线程 order-sse,心跳 {}s)", PING_INTERVAL_SECONDS);
+    }
+
+    private void pingAll() {
+        pingBucket(superAdmins);
+        for (List<SseEmitter> bucket : byTenant.values()) {
+            pingBucket(bucket);
+        }
+    }
+
+    private void pingBucket(List<SseEmitter> bucket) {
+        for (SseEmitter emitter : bucket) {
+            if (!sendPing(emitter)) {
+                remove(bucket, emitter);
+            }
+        }
+    }
+
+    @PreDestroy
+    void shutdown() {
+        if (heartbeat != null) {
+            heartbeat.cancel(false);
+        }
+        executor.shutdownNow();
+    }
+
+    /** 仅测试用:当前连接总数。 */
+    int connectionCount() {
+        int n = superAdmins.size();
+        for (List<SseEmitter> bucket : byTenant.values()) {
+            n += bucket.size();
+        }
+        return n;
+    }
+
+    /** 仅测试用。 */
+    int tenantConnectionCount(long tenantId) {
+        List<SseEmitter> bucket = byTenant.get(tenantId);
+        return bucket == null ? 0 : bucket.size();
+    }
+
+    /** 仅测试用:还留着几个租户桶(空桶应被删掉)。 */
+    int tenantBucketCount() {
+        return byTenant.size();
+    }
+}
+```
+
+- [ ] **Step 4: 跑它,确认绿**
+
+Run: `cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest=OrderEventHubTest`
+Expected: `Tests run: 6, Failures: 0, Errors: 0, Skipped: 0`
+
+- [ ] **Step 5: 变异验证(两向:该红的必须红,还原后必须仍绿)**
+
+逐个做,每个做完**还原**再跑一次确认仍绿:
+
+| 变异(只改一处) | 期望变红的用例 |
+|---|---|
+| `broadcast` 里 `deliver(superAdmins, ...)` 后面加一句对所有 `byTenant.values()` 投递(忽略 tenantId 路由) | `broadcast_routesOnlyToTheSameTenant` |
+| `submitBroadcast` 改成直接 `broadcast(channel, tenantId)`(去掉执行器) | `submitBroadcast_everyFrameGoesOutOnTheOrderSseThread`(frameThreads 里会出现 `pool-N-thread-M`) |
+| `register` 里超管也 `byTenant.computeIfAbsent(...)` 加一份 | `register_superAdminWithTenantId_doesNotDoubleReceive` |
+| `remove` 里删掉空桶清理那一句 | `broadcast_sendFailure_removesConnectionAndEmptyBucket` |
+
+任一变异**没有**让对应用例变红 ⇒ 停手,说明那条断言没有真的量到东西,先修测试。
+
+- [ ] **Step 6: 提交**
+
+```bash
+git add java/src/main/java/com/payment/platform/module/payment/stream/OrderEventHub.java \
+        java/src/test/java/com/payment/platform/module/payment/stream/OrderEventHubTest.java
+git commit -m "feat(订单实时): 新增订单事件广播 Hub —— SSE 桶 + 心跳 + 单线程串行"
+```
+
+### Task 2: 后端 —— 事件三件套(事件 / 发布器 / 提交后转发)
+
+**Files:**
+- Create: `java/src/main/java/com/payment/platform/module/payment/stream/OrderChangedEvent.java`
+- Create: `java/src/main/java/com/payment/platform/module/payment/stream/OrderEventPublisher.java`
+- Create: `java/src/main/java/com/payment/platform/module/payment/stream/OrderEventRelay.java`
+- Test: `java/src/test/java/com/payment/platform/module/payment/stream/OrderEventPublisherTest.java`
+- Test: `java/src/test/java/com/payment/platform/module/payment/stream/OrderEventRelayTest.java`
+
+**Interfaces:**
+- Consumes: `OrderEventHub.submitBroadcast(String, Long)`(Task 1)
+- Produces(Task 4-7 的**每一个**发布点都长这样一行):
+  - `public void OrderEventPublisher.changed(String channel, Long tenantId)`
+  - 常量 `OrderEventPublisher.CHANNEL_LSY_ORDER = "lsy_order"` / `CHANNEL_LSY_REFUND = "lsy_refund"` / `CHANNEL_HAR_ORDER = "har_order"`(**与前端脚本钉的是同一组串**)
+  - `public record OrderChangedEvent(String channel, Long tenantId)`
+
+- [ ] **Step 1: 写失败的测试**
+
+创建 `OrderEventPublisherTest.java`:
+
+```java
+package com.payment.platform.module.payment.stream;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class OrderEventPublisherTest {
+
+    private final List<Object> published = new CopyOnWriteArrayList<>();
+    // ApplicationEventPublisher 是 @FunctionalInterface(唯一抽象方法 publishEvent(Object)),
+    // 这里直接拿 List::add 当实现 —— 不需要 Spring 容器
+    private final OrderEventPublisher publisher = new OrderEventPublisher(published::add);
+
+    @Test
+    void changed_publishesOneEventWithChannelAndTenant() {
+        publisher.changed(OrderEventPublisher.CHANNEL_LSY_ORDER, 7L);
+
+        assertEquals(1, published.size());
+        OrderChangedEvent event = (OrderChangedEvent) published.get(0);
+        assertEquals("lsy_order", event.channel());
+        assertEquals(7L, event.tenantId());
+    }
+
+    @Test
+    void channelNames_areTheWireContractWithFrontend() {
+        // 与前端 scripts/verify-order-stream.mjs 里钉的三个串必须逐字一致:改名 = 断链
+        assertEquals("lsy_order", OrderEventPublisher.CHANNEL_LSY_ORDER);
+        assertEquals("lsy_refund", OrderEventPublisher.CHANNEL_LSY_REFUND);
+        assertEquals("har_order", OrderEventPublisher.CHANNEL_HAR_ORDER);
+    }
+
+    @Test
+    void changed_allowsNullTenant() {
+        publisher.changed(OrderEventPublisher.CHANNEL_HAR_ORDER, null);
+
+        assertEquals(1, published.size());
+        assertNull(((OrderChangedEvent) published.get(0)).tenantId());
+    }
+}
+```
+
+创建 `OrderEventRelayTest.java`:
+
+```java
+package com.payment.platform.module.payment.stream;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+import java.lang.reflect.Method;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class OrderEventRelayTest {
+
+    static class CapturingHub extends OrderEventHub {
+        final List<String> submitted = new CopyOnWriteArrayList<>();
+
+        @Override
+        public void submitBroadcast(String channel, Long tenantId) {
+            submitted.add(channel + "#" + tenantId);
+        }
+    }
+
+    private final CapturingHub hub = new CapturingHub();
+    private final OrderEventRelay relay = new OrderEventRelay(hub);
+
+    @AfterEach
+    void tearDown() {
+        hub.shutdown();
+    }
+
+    @Test
+    void onOrderChanged_delegatesToHub() {
+        relay.onOrderChanged(new OrderChangedEvent("lsy_order", 5L));
+
+        assertEquals(List.of("lsy_order#5"), hub.submitted);
+    }
+
+    @Test
+    void theAfterCommitSemanticsArePinnedInCode() throws Exception {
+        // 容器级测试要拉 Spring 上下文(本仓 81 个测试类一个都没拉过,会撞 leshouying.enabled
+        // 启动闸与库隧道依赖),所以「提交后才投递 + 无事务也投递」这条语义在这里用注解断言钉住;
+        // 真事务行为由 Task 14 的开放 API 建单 E2E 端到端验(信号早于提交 ⇒ 重拉看不到新行 ⇒ 红)。
+        Method m = OrderEventRelay.class.getMethod("onOrderChanged", OrderChangedEvent.class);
+        TransactionalEventListener ann = m.getAnnotation(TransactionalEventListener.class);
+        assertNotNull(ann, "没有这个注解 = 事件永远不被投递");
+        assertEquals(TransactionPhase.AFTER_COMMIT, ann.phase(),
+                "改回 BEFORE_COMMIT 会造出「信号先到、库还没提交」的竞态");
+        assertTrue(ann.fallbackExecution(),
+                "false = 乐收赢那些刻意无事务的写库腿一帧都发不出去(静默失效)");
+    }
+}
+```
+
+- [ ] **Step 2: 跑它,确认红**
+
+Run: `cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest='OrderEventPublisherTest+OrderEventRelayTest'`
+Expected: 编译失败 `cannot find symbol: class OrderEventPublisher / OrderChangedEvent / OrderEventRelay`
+
+- [ ] **Step 3: 写实现**
+
+创建 `OrderChangedEvent.java`:
+
+```java
+package com.payment.platform.module.payment.stream;
+
+/**
+ * 订单数据变了(任何写入)。
+ *
+ * <p>只带通道与租户、**不带数据** —— 页面拿信号去重拉它自己的列表接口,
+ * 租户隔离/分页/筛选/权限全部免费复用(见 spec「投递语义」)。
+ *
+ * @param channel  见 {@link OrderEventPublisher} 的三个通道常量
+ * @param tenantId 归属租户;可空
+ */
+public record OrderChangedEvent(String channel, Long tenantId) {
+}
+```
+
+创建 `OrderEventPublisher.java`:
+
+```java
+package com.payment.platform.module.payment.stream;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Component;
+
+/**
+ * 写库方的唯一入口:**行真的改了之后**调 {@link #changed}(守卫没命中的分支不许发,
+ * 那种分支什么都没变,发了就是让页面白拉一次)。
+ *
+ * <p>通道名是与前端的线上契约(前端按通道名决定重拉哪个列表)——改名 = 断链。
+ * 后端 {@code OrderEventPublisherTest} 与前端 {@code scripts/verify-order-stream.mjs} 各钉了一遍。
+ */
+@Component
+@RequiredArgsConstructor
+public class OrderEventPublisher {
+
+    /** 乐收赢支付订单表 {@code lsy_order}。 */
+    public static final String CHANNEL_LSY_ORDER = "lsy_order";
+    /** 乐收赢退款表 {@code lsy_refund}。 */
+    public static final String CHANNEL_LSY_REFUND = "lsy_refund";
+    /** 汇安融订单表 {@code har_order}。 */
+    public static final String CHANNEL_HAR_ORDER = "har_order";
+
+    private final ApplicationEventPublisher events;
+
+    public void changed(String channel, Long tenantId) {
+        events.publishEvent(new OrderChangedEvent(channel, tenantId));
+    }
+}
+```
+
+创建 `OrderEventRelay.java`:
+
+```java
+package com.payment.platform.module.payment.stream;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+/**
+ * 事件 → 广播。两件要紧事:
+ *
+ * <ol>
+ *   <li>{@code AFTER_COMMIT}:开放 API 建单那条腿**有事务**,在事务里广播会让页面收到信号
+ *       去重拉、却看不到尚未提交的新行 —— 比不推更糟。</li>
+ *   <li>{@code fallbackExecution = true}:乐收赢的写库腿**刻意不加事务**(单行条件更新),
+ *       没有事务时照常立即投递;不写它那些通道一帧都发不出去,而且是静默的。</li>
+ * </ol>
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class OrderEventRelay {
+
+    private final OrderEventHub hub;
+
+    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
+    public void onOrderChanged(OrderChangedEvent event) {
+        hub.submitBroadcast(event.channel(), event.tenantId());
+    }
+}
+```
+
+- [ ] **Step 4: 跑它,确认绿**
+
+Run: `cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest='OrderEventPublisherTest+OrderEventRelayTest'`
+Expected: `Tests run: 5, Failures: 0, Errors: 0, Skipped: 0`
+
+- [ ] **Step 5: 变异验证**
+
+| 变异 | 期望变红 |
+|---|---|
+| relay 的注解 `fallbackExecution = true` 改成默认(删掉参数) | `theAfterCommitSemanticsArePinnedInCode` |
+| relay 的 `phase` 改成 `BEFORE_COMMIT` | 同上 |
+| `OrderEventPublisher.CHANNEL_LSY_REFUND` 改成 `"lsy_refunds"` | `channelNames_areTheWireContractWithFrontend` |
+
+- [ ] **Step 6: 提交**
+
+```bash
+git add java/src/main/java/com/payment/platform/module/payment/stream/OrderChangedEvent.java \
+        java/src/main/java/com/payment/platform/module/payment/stream/OrderEventPublisher.java \
+        java/src/main/java/com/payment/platform/module/payment/stream/OrderEventRelay.java \
+        java/src/test/java/com/payment/platform/module/payment/stream/OrderEventPublisherTest.java \
+        java/src/test/java/com/payment/platform/module/payment/stream/OrderEventRelayTest.java
+git commit -m "feat(订单实时): 领域事件三件套 —— 提交后转发(无事务兜底)"
+```
+
+---
+
+### Task 3: 后端 —— SSE 订阅端点
+
+**Files:**
+- Create: `java/src/main/java/com/payment/platform/module/payment/stream/OrderStreamController.java`
+- Test: `java/src/test/java/com/payment/platform/module/payment/stream/OrderStreamControllerTest.java`
+
+**Interfaces:**
+- Consumes: `OrderEventHub.register(Long, boolean)`(Task 1)
+- Produces: `GET /api/v1/payment/stream/orders`(`text/event-stream`,**前端 Task 8 就调这个 URL**)
+  - 查询参数 `?token=<access_token>`:EventSource 不能自定义请求头,鉴权靠 `JwtAuthFilter.extractToken` 对 `?token=` 的现成支持
+  - 响应头 `X-Accel-Buffering: no` + `Cache-Control: no-cache`
+
+**安全说明(不许省)**:本端点**不加** `@PreAuthorize` —— 任何登录用户都可以订阅,但**推给他的帧只属于他自己的租户桶**(超管走超管桶)。即使有人伪造租户也无意义:帧里只有通道名、没有数据,重拉走的是带租户过滤的列表接口。`SecurityConfig` 的 `anyRequest().authenticated()` 已经覆盖这个新路径,`SecurityConfig` 不需要改。取当前用户的写法照抄本仓既有惯用法(`SecurityContextHolder` + `instanceof LoginUser`,见 `LeshouyingCashierController#requireTenantId` 附近;本仓 0 处 `@AuthenticationPrincipal`,别新造一个)。
+
+- [ ] **Step 1: 写失败的测试**
+
+创建 `OrderStreamControllerTest.java`:
+
+```java
+package com.payment.platform.module.payment.stream;
+
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.core.security.LoginUser;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class OrderStreamControllerTest {
+
+    private final OrderEventHub hub = new OrderEventHub();
+    private final OrderStreamController controller = new OrderStreamController(hub);
+    private final MockHttpServletResponse response = new MockHttpServletResponse();
+
+    @AfterEach
+    void tearDown() {
+        SecurityContextHolder.clearContext();
+        hub.shutdown();
+    }
+
+    private static void login(Long tenantId, boolean superuser) {
+        LoginUser user = new LoginUser();
+        user.setTenantId(tenantId);
+        user.setIsSuperuser(superuser);
+        SecurityContextHolder.getContext().setAuthentication(
+                new UsernamePasswordAuthenticationToken(user, null, List.of()));
+    }
+
+    @Test
+    void orders_tenantUser_opensOneTenantConnectionAndDeclaresNoBuffering() {
+        login(2L, false);
+
+        SseEmitter emitter = controller.orders(response);
+
+        assertNotNull(emitter);
+        assertEquals("no", response.getHeader("X-Accel-Buffering"),
+                "不写这条头,nginx 会把 SSE 帧攒着转发,页面看起来「不实时」");
+        assertEquals(1, hub.connectionCount());
+        assertEquals(1, hub.tenantConnectionCount(2L));
+    }
+
+    @Test
+    void orders_superAdmin_goesToSuperAdminBucketOnly() {
+        login(2L, true);
+
+        controller.orders(response);
+
+        assertEquals(1, hub.connectionCount());
+        assertEquals(0, hub.tenantConnectionCount(2L), "超管不进租户桶 —— 否则同帧收两份");
+    }
+
+    @Test
+    void orders_anonymous_throws403AndOpensNothing() {
+        BusinessException ex = assertThrows(BusinessException.class, () -> controller.orders(response));
+
+        assertTrue(ex.getMessage().contains("未登录"), "实际文案=" + ex.getMessage());
+        assertEquals(0, hub.connectionCount(), "匿名不许往桶里挂空连接");
+    }
+}
+```
+
+- [ ] **Step 2: 跑它,确认红**
+
+Run: `cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest=OrderStreamControllerTest`
+Expected: 编译失败 `cannot find symbol: class OrderStreamController`
+
+- [ ] **Step 3: 写实现**
+
+创建 `OrderStreamController.java`:
+
+```java
+package com.payment.platform.module.payment.stream;
+
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.core.security.LoginUser;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.MediaType;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+/**
+ * 订单实时信号长连接(SSE)。
+ *
+ * <p>身份取自 Security 上下文(与全仓 6 个控制器同一惯用法)。EventSource 不能自定义请求头,
+ * 所以浏览器那条腿靠 {@code JwtAuthFilter} 对 {@code ?token=} 的现成支持;服务端不额外做令牌轮询
+ * (滑动过期下 JWT 的 exp 不是权威源)。
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/payment/stream")
+@RequiredArgsConstructor
+public class OrderStreamController {
+
+    private final OrderEventHub hub;
+
+    @GetMapping(value = "/orders", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+    public SseEmitter orders(HttpServletResponse response) {
+        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+        LoginUser user = (auth != null && auth.getPrincipal() instanceof LoginUser u) ? u : null;
+        if (user == null) {
+            // anyRequest().authenticated() 已经挡在前面;这里是最后一道,免得「匿名却通过了」
+            // 时往桶里挂一条谁都收不到的空连接
+            throw new BusinessException(403, "未登录,无法建立实时连接");
+        }
+        // nginx 默认会缓冲上游响应:不写这条头,帧会被攒着 —— 本机直连看不出来,部署后才暴露
+        response.setHeader("X-Accel-Buffering", "no");
+        response.setHeader("Cache-Control", "no-cache");
+        return hub.register(user.getTenantId(), Boolean.TRUE.equals(user.getIsSuperuser()));
+    }
+}
+```
+
+- [ ] **Step 4: 跑它,确认绿**
+
+Run: `cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest='OrderStreamControllerTest'`
+Expected: `Tests run: 3, Failures: 0, Errors: 0, Skipped: 0`
+
+- [ ] **Step 5: 全量回归(确认没有波及)**
+
+Run: `cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test`
+Expected: 本次 stdout 汇总行 `Tests run: <N>, Failures: 0, Errors: 0, Skipped: 0`(N = 既有 81 个类 + 新增 4 个类;**读汇总行,不要读 surefire-reports 目录**——它跨运行累积)
+
+- [ ] **Step 6: 变异验证**
+
+| 变异 | 期望变红 |
+|---|---|
+| `Boolean.TRUE.equals(user.getIsSuperuser())` 改成 `true`(人人当超管) | `orders_tenantUser_opensOneTenantConnectionAndDeclaresNoBuffering` |
+| 去掉 `response.setHeader("X-Accel-Buffering", "no")` | 同上的第二条断言 |
+| `user == null` 分支改成 `hub.register(null, false)` 继续往下走 | `orders_anonymous_throws403AndOpensNothing` |
+
+- [ ] **Step 7: 提交**
+
+```bash
+git add java/src/main/java/com/payment/platform/module/payment/stream/OrderStreamController.java \
+        java/src/test/java/com/payment/platform/module/payment/stream/OrderStreamControllerTest.java
+git commit -m "feat(订单实时): SSE 订阅端点 /payment/stream/orders(复用 ?token= 鉴权)"
+```
+
+### Task 4: lsy 订单状态收口点发布(含 6 参构造波及)
+
+**Files:**
+- Modify: `java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOrderService.java`(字段块末尾 + 两处 `return true;` 之前)
+- Modify(仅构造点):`LsyOrderServiceStateTest.java:43`、`LsyBarcodePayTest.java:37`、`LsyH5PayTest.java:37`、`LsyOrderUpstreamRejectTest.java:53`、`LsyQrPayTest.java:43`
+- Test: 在上面的 `LsyOrderServiceStateTest.java` 里加 4 个新用例
+
+**Interfaces:**
+- Consumes: `OrderEventPublisher.changed(String, Long)`、`CHANNEL_LSY_ORDER`(Task 2)
+- Produces: 无新 API;`LsyOrderService` 构造器 5 参 → **6 参**(`…, LsyQueryService queryService, OrderEventPublisher publisher)`)
+
+**为什么收口在这两处**:`applyPayResult` 是全仓唯一的「订单状态回写」收口(建单尾、支付回调、轮询确认、关单拒绝、对账都用它),`applyRefundResult` 是退款结算改订单行(`refunded_amount` / 状态)的唯一收口 —— 收在这里,9 个调用点一次全覆盖,且天然满足「守卫没命中就什么都不发」。
+
+- [ ] **Step 1: 写失败的测试**
+
+改 `LsyOrderServiceStateTest.java`:`@Mock` 块末尾加一行,`setUp` 改成 6 参:
+
+```java
+    @Mock private LsyOrderMapper orderMapper;
+    @Mock private LeshouyingClient client;
+    @Mock private LsyQueryService queryService;
+    @Mock private OrderEventPublisher publisher;      // ← 新增
+```
+
+```java
+        props = new LeshouyingProperties();
+        // 构造器签名固定为 6 参(2026-10-05 加 OrderEventPublisher)—— 后续新增下单方法时不要改它
+        service = new LsyOrderService(orderMapper, client,
+                new LeshouyingResponseParser(), props, queryService, publisher);
+```
+
+在类尾追加 4 个新用例(夹具照抄同类既有用例:`order(...)`/`patch(...)` 是类内现成的私有工厂):
+
+```java
+    @Test
+    void applyPayResult_affectedRowIsOne_emitsLsyOrderSignal() {
+        when(orderMapper.update(any(), any(UpdateWrapper.class))).thenReturn(1);
+        LsyOrderEntity o = order("PAYING");
+        o.setTenantId(2L);
+
+        assertTrue(service.applyPayResult(o, "SUCCESS", patch("T1")));
+
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_LSY_ORDER, 2L);
+    }
+
+    @Test
+    void applyPayResult_affectedRowIsZero_emitsNothing() {
+        when(orderMapper.update(any(), any(UpdateWrapper.class))).thenReturn(0);
+
+        assertFalse(service.applyPayResult(order("SUCCESS"), "SUCCESS", patch("T1")));
+
+        // 重复通知(条件更新未命中)一行都没改 —— 发了就是让三页白拉一次
+        verifyNoInteractions(publisher);
+    }
+
+    @Test
+    void applyRefundResult_settledRow_emitsLsyOrderSignal() {
+        when(orderMapper.update(any(), any(UpdateWrapper.class))).thenReturn(1);
+        LsyOrderEntity o = order("SUCCESS");
+        o.setTenantId(2L);
+
+        service.applyRefundResult(o, 100L, "REFUNDED");
+
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_LSY_ORDER, 2L);
+    }
+
+    @Test
+    void applyRefundResult_zeroRows_emitsNothing() {
+        when(orderMapper.update(any(), any(UpdateWrapper.class))).thenReturn(0);
+
+        service.applyRefundResult(order("CANCELED"), 100L, "REFUNDED");
+
+        verifyNoInteractions(publisher);
+    }
+```
+
+外加 import:`import com.payment.platform.module.payment.stream.OrderEventPublisher;`
+
+- [ ] **Step 2: 跑它,确认红**
+
+Run: `cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest=LsyOrderServiceStateTest`
+Expected: 编译失败 `actual and formal argument lists differ in length`(6 参构造器还不存在)——Java 里这就是「函数签名不存在」的等价红。
+
+- [ ] **Step 3: 改实现 + 修另外 4 个构造点**
+
+`LsyOrderService.java` —— import 加 `import com.payment.platform.module.payment.stream.OrderEventPublisher;`,字段块**末尾**追加(现有字段块 :40-44,末行是 `private final LsyQueryService queryService;`):
+
+```java
+    /** 订单信号发布(SSE):只在**真的改了行**之后发 —— 守卫没命中的分支一行都没变,
+     *  发了就是让三页白拉一次。 */
+    private final OrderEventPublisher publisher;
+```
+
+发布点 A —— `applyPayResult` 成功尾(`if (patch.getUpstreamResultCode() != null) { … }` 之后、`return true;` 之前,:152-156 附近):
+
+```java
+            if (patch.getUpstreamResultCode() != null) {
+                order.setUpstreamResultCode(patch.getUpstreamResultCode());
+            }
+            publisher.changed(OrderEventPublisher.CHANNEL_LSY_ORDER, order.getTenantId());
+            return true;
+```
+
+发布点 B —— `applyRefundResult` 的 `if (rows == 1)` 块内(结算成功的 return 之前):
+
+```java
+        int rows = orderMapper.update(patch, w);
+        if (rows == 1) {
+            order.setStatus(newStatus);
+            order.setRefundedAmount(accumulated);
+            publisher.changed(OrderEventPublisher.CHANNEL_LSY_ORDER, order.getTenantId());
+            return true;
+        }
+```
+
+另外 4 个测试类:`@Mock` 块里加 `@Mock private OrderEventPublisher publisher;`,`new LsyOrderService(...)` 实参表**尾**追加 `, publisher`(都是多行调用,改的是收尾 `);` 前那一行)。改完自检:
+
+```bash
+cd java && grep -rn -A2 "new LsyOrderService(" src/test/java | grep -c "publisher"
+```
+Expected: `5`(5 个构造点全部带上)
+
+- [ ] **Step 4: 跑它,确认绿**
+
+Run: `cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest='LsyOrderServiceStateTest+LsyBarcodePayTest+LsyH5PayTest+LsyOrderUpstreamRejectTest+LsyQrPayTest'`
+Expected: `Failures: 0, Errors: 0`(只跑这 5 个类;`LsyRefundServiceTest` 此刻还是 5 参构造、不受影响)
+
+- [ ] **Step 5: 变异验证**
+
+| 变异 | 期望变红 |
+|---|---|
+| 删掉发布点 A 那一行 | `applyPayResult_affectedRowIsOne_emitsLsyOrderSignal` |
+| 把发布点 A 挪到 `int rows =` 之前(无论命中与否都发) | `applyPayResult_affectedRowIsZero_emitsNothing` |
+| 发布点 B 的通道名写成 `CHANNEL_HAR_ORDER` | `applyRefundResult_settledRow_emitsLsyOrderSignal` |
+| `order.getTenantId()` 换成 `null` | 上面两条 emit 用例(租户丢了) |
+
+- [ ] **Step 6: 提交**
+
+```bash
+git add java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOrderService.java \
+        java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyOrderServiceStateTest.java \
+        java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyBarcodePayTest.java \
+        java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyH5PayTest.java \
+        java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyOrderUpstreamRejectTest.java \
+        java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyQrPayTest.java
+git commit -m "feat(订单实时): lsy 订单状态两个收口点发布信号(6 参构造波及 5 个测试类)"
+```
+
+---
+
+### Task 5: lsy 订单表其余写点(建单 insert ×4 / 关单撤销 ×3)+ 审计回归
+
+**Files:**
+- Modify: `leshouying/controller/LeshouyingCashierController.java`(字段 + insert + 收银台撤销)
+- Modify: `leshouying/controller/LeshouyingCheckoutController.java`(字段 + insert)
+- Modify: `leshouying/controller/open/LeshouyingOpenController.java`(字段 + 3 处 insert + 关单)
+- Modify: `leshouying/scheduler/LsyOrderPollScheduler.java`(字段 + 自动撤销)
+- Modify: `leshouying/scheduler/LsyReconcileScheduler.java`(字段 + 关单 + 过期)
+- Modify(仅构造点):`LeshouyingCashierControllerTest.java:104`、`LeshouyingCheckoutControllerTest.java:74`、`LeshouyingOpenControllerTest.java:67/:319/:367`、`LeshouyingOpenApiErrorContractTest.java:93`、`LsySchedulerTest.java`(**14 处**:`new LsyOrderPollScheduler(` ×7 = :105/:119/:133/:162/:194/:221/:233,`new LsyReconcileScheduler(` ×7 = :247/:263/:296/:347/:374/:403/:520)
+
+**Interfaces:**
+- Consumes: `OrderEventPublisher.changed(String, Long)`、`CHANNEL_LSY_ORDER`
+- Produces: 全部写 `lsy_order` 的腿都带信号;五个类各有 1 个新构造器尾参 `OrderEventPublisher publisher`
+
+**发布点清单(逐处插入,全部用 `order.getTenantId()` + `CHANNEL_LSY_ORDER`)**:
+
+| # | 文件 | 落点 |
+|---|---|---|
+| 1 | `LeshouyingOpenController` | 3 处 `orderMapper.insert(order);`(:89 / :116 / :141,`grep -n "orderMapper.insert(order);"` 应恰好 3 处)之后各一行 |
+| 2 | `LeshouyingOpenController` | `orderClose`:`order.setStatus(target);` 与 `data.put("status", target);` 之间 |
+| 3 | `LeshouyingCashierController` | `orderMapper.insert(order);`(:180)之后 |
+| 4 | `LeshouyingCashierController` | 收银台撤销:`order.setStatus(target);` 与 `return Result.ok(toVo(order, "已撤销"));` 之间 |
+| 5 | `LeshouyingCheckoutController` | `orderMapper.insert(order);`(:208,在 SecurityContext 切换块内)之后 |
+| 6 | `LsyOrderPollScheduler` | 自动撤销分支:`order.setStatus(target);` 与 `callbackService.notifyOrder(order);` 之间(:195 附近,16 空格缩进那处) |
+| 7 | `LsyReconcileScheduler` | `applyCloseResult`:`order.setStatus(target);` 与 `callbackService.notifyOrder(order);` 之间(:499 附近,8 空格缩进) |
+| 8 | `LsyReconcileScheduler` | `markExpired`:`order.setStatus("EXPIRED");` 与 `callbackService.notifyOrder(order);` 之间(:562 附近) |
+
+插入的这一行(每个点都一样,只有空格缩进跟着上下文):
+
+```java
+        publisher.changed(OrderEventPublisher.CHANNEL_LSY_ORDER, order.getTenantId());
+```
+
+**不发布的三处(有意为之,审计时不是遗漏)**:`LsyOrderService.stageCodeUrl` / `markNextQuery`(游标类 `updateById`,订单列表页看不到)、`LsyCallbackService.recordOrderNotifyResult`(`notify_status` 列 2026-10-05 已从订单页下线,`order/index.vue:145/:272` 有「别顺手补回来」的注释)。
+
+- [ ] **Step 1: 写失败的测试**
+
+在每个既有测试类里加用例,**夹具照抄括号里点名的既有用例**,只加发布断言:
+
+- `LeshouyingOpenControllerTest`(夹具抄 `barcodePay_mapsDtoToEntityAndReturnsResult` :87):
+
+```java
+    @Test
+    void barcodePay_publishesLsyOrderSignal() {
+        // 夹具与 barcodePay_mapsDtoToEntityAndReturnsResult 逐行一致(同 mock 同入参)
+        …(照抄该用例的 when/stub 与 controller 调用)…
+
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_LSY_ORDER, TENANT_ID);
+    }
+```
+
+- `LeshouyingOpenControllerTest`(夹具抄 `orderClose_条件更新命中时回写目标状态` :536 / `orderClose_条件更新未命中时不得谎报状态` :517):
+
+```java
+    @Test
+    void orderClose_条件更新命中时_发一条信号() {
+        …
+        // 断言:先 verify(publisher).changed(CHANNEL_LSY_ORDER, TENANT_ID);
+    }
+
+    @Test
+    void orderClose_条件更新未命中_不发信号() {
+        …
+        verifyNoInteractions(publisher);   // 一个字节都没改,发了就是让页面白拉
+    }
+```
+
+- `LeshouyingCashierControllerTest`(夹具抄 `create_assemblesTheOrder_rowShapeAndServerDecidedFields` :252 / `close_appliesGuardedUpdate_andReportsTheNewStatus` :454 / `close_whenGuardMisses_reportsNotApplied_neverSuccess` :486):三个用例,分别 `verify(publisher).changed(...)` / `verify(publisher).changed(...)` / `verifyNoInteractions(publisher)`。
+- `LeshouyingCheckoutControllerTest`(夹具抄 `create_assemblesTheOrder_commitsIt_thenCallsTheRealQrPayLeg` :159):`verify(publisher).changed(OrderEventPublisher.CHANNEL_LSY_ORDER, TENANT)`(类内已有常量 `TENANT = 42L`)。
+- `LsySchedulerTest`:在三条既有用例上各加一条发布断言 —— 自动撤销 `pollBarcode_exhausted_triggersAutoCancel`(:153)、过期 `expireStale_stillPaying_expiresAndNotifies`(:339)、关单 `applyCloseResult_命中一行时回写并通知下游`(:397);在三条守卫未命中的既有用例上各加一条 `verifyNoInteractions(publisher)` —— `pollBarcode_exhausted_未命中时不得覆盖状态也不得通知下游`(:185)、`expireStale_alreadySettled_doesNotOverwriteOrNotify`(:290)、`applyCloseResult_未命中时不得覆盖状态也不得通知下游`(:367)。断言写进**既有用例体内**(三页白拉一次的成本就在这些分支上,单独写新用例反而会把夹具再抄一遍)。
+
+每个测试类都要:`@Mock private OrderEventPublisher publisher;` + 构造点尾追加 `, publisher` + import。
+
+- [ ] **Step 2: 跑它,确认红**
+
+Run: `cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest='LeshouyingOpenControllerTest+LeshouyingCashierControllerTest+LeshouyingCheckoutControllerTest+LsySchedulerTest+LeshouyingOpenApiErrorContractTest'`
+Expected: 编译失败(构造器 5 参/4 参不匹配)—— 改完构造点后重跑,新用例因 `Wanted but not invoked` 变红。
+
+- [ ] **Step 3: 改实现(8 个发布点 + 5 个字段)**
+
+5 个类的字段都加在各自字段块**末尾**:
+
+- `LeshouyingOpenController`:字段块末尾是 `private final HttpServletRequest request;`(:67)
+- `LeshouyingCashierController`:`private final LsyOrderMapper orderMapper;`(:161)
+- `LeshouyingCheckoutController`:`private final LsyOrderMapper orderMapper;`(:144)
+- `LsyOrderPollScheduler`:`private final LeshouyingProperties props;`(:116)
+- `LsyReconcileScheduler`:`private final LeshouyingProperties props;`(:127)
+
+字段体(照抄 Task 4 的注释):
+
+```java
+    /** 订单信号发布(SSE):只在**真的改了行**之后发。 */
+    private final OrderEventPublisher publisher;
+```
+
+按上表插 8 行发布。插完自检(每一处都该是 1):
+
+```bash
+cd java/src/main/java/com/payment/platform/module/payment/leshouying
+grep -c "publisher.changed" controller/open/LeshouyingOpenController.java   # 期望 4
+grep -c "publisher.changed" controller/LeshouyingCashierController.java    # 期望 2
+grep -c "publisher.changed" controller/LeshouyingCheckoutController.java   # 期望 1
+grep -c "publisher.changed" scheduler/LsyOrderPollScheduler.java           # 期望 1
+grep -c "publisher.changed" scheduler/LsyReconcileScheduler.java           # 期望 2
+```
+
+- [ ] **Step 4: 审计(防止漏网写点)**
+
+```bash
+cd java/src/main/java/com/payment/platform/module/payment/leshouying
+# lsy_order 表的所有写调用点(insert / update / updateById)
+grep -rn "orderMapper\.\(insert\|update\|updateById\)(" . | grep -v "\.java:.*//"
+```
+逐条对照:每条要么在 Task 4/5 已加的发布点**紧邻**(同一方法、发布行就在写语句之后),要么属于「不发布的三处」清单。**出现第四类就停下来问**,别自行判断。
+
+- [ ] **Step 5: 跑全量,确认绿**
+
+Run: `cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test`
+Expected: 本次 stdout 汇总行 `Failures: 0, Errors: 0`(读汇总行)
+
+- [ ] **Step 6: 变异验证(每类抽一个)**
+
+| 变异 | 期望变红 |
+|---|---|
+| 删掉 `LeshouyingOpenController` 三处 insert 发布中的任意一处 | 对应的 `*_publishesLsyOrderSignal` 用例 |
+| `orderClose` 的发布行挪到条件更新**之前** | `orderClose_条件更新未命中_不发信号` |
+| `markExpired` 的发布行删掉 | markExpired 那条新断言 |
+
+- [ ] **Step 7: 提交**
+
+```bash
+git add java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCashierController.java \
+        java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCheckoutController.java \
+        java/src/main/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenController.java \
+        java/src/main/java/com/payment/platform/module/payment/leshouying/scheduler/LsyOrderPollScheduler.java \
+        java/src/main/java/com/payment/platform/module/payment/leshouying/scheduler/LsyReconcileScheduler.java \
+        java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCashierControllerTest.java \
+        java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCheckoutControllerTest.java \
+        java/src/test/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenControllerTest.java \
+        java/src/test/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenApiErrorContractTest.java \
+        java/src/test/java/com/payment/platform/module/payment/leshouying/scheduler/LsySchedulerTest.java
+git commit -m "feat(订单实时): lsy 建单/关单/自动撤销/过期八处写点发布信号"
+```
+
+### Task 6: lsy 退款表(lsy_refund)写点
+
+**Files:**
+- Modify: `leshouying/service/LsyRefundService.java`(字段块末尾 + 4 处写点)
+- Modify(仅构造点):`LsyRefundServiceTest.java:40`(7 参 → 8 参)
+- Test: 在 `LsyRefundServiceTest.java` 里加 5 条断言
+
+**Interfaces:**
+- Consumes: `OrderEventPublisher.changed(String, Long)`、`CHANNEL_LSY_REFUND`
+- Produces: `LsyRefundService` 构造器 7 参 → **8 参**(尾参 `OrderEventPublisher publisher`)
+
+**发布点(4 处,全部 `CHANNEL_LSY_REFUND` + 实体自己的 `getTenantId()`)**:
+
+| # | 方法 | 落点(锚点文本) |
+|---|---|---|
+| 1 | `createRefund` | `refundMapper.insert(entity);`(:235,全仓唯一 insert 点)之后 |
+| 2 | `persistUpstreamIdentity(LsyRefundEntity entity)` | 方法末 `refundMapper.update(patch, w);` 之后 |
+| 3 | `persistRefundSettle(LsyRefundEntity refund, …)` | 方法末 `refundMapper.update(patch, w);` 之后(`settle == null` 的早退分支不发 —— 那一支什么都没写) |
+| 4 | `applyNotifyResult` | `refund.setUpstreamResultMsg(resultMsg);` 之后、`pushRefundCallbackIfTerminal(refund);` 之前 |
+
+- [ ] **Step 1: 写失败的测试**
+
+`LsyRefundServiceTest.java`:`@Mock` 块加 `@Mock private OrderEventPublisher publisher;`,:40 的构造点尾追加 `, publisher`,然后加断言(夹具照抄括号里的既有用例,只加发布断言):
+
+```java
+    // ① 夹具抄 refund_预插入CREATED_且早于上游调用(:233)
+    verify(publisher).changed(OrderEventPublisher.CHANNEL_LSY_REFUND, TENANT);
+
+    // ② 夹具抄 refund_上游响应里的字段_补写到预插入的那一行(:313)
+    verify(publisher).changed(OrderEventPublisher.CHANNEL_LSY_REFUND, TENANT);
+
+    // ③ 夹具抄 settleFromQuery_退款查询带refund_settle_落库(:765)
+    verify(publisher).changed(OrderEventPublisher.CHANNEL_LSY_REFUND, TENANT);
+
+    // ④ 夹具抄 applyNotifyResult_transitionApplied_returnsTrue(:859)
+    verify(publisher).changed(OrderEventPublisher.CHANNEL_LSY_REFUND, TENANT);
+
+    // ⑤ 夹具抄 applyNotifyResult_duplicateCallback_returnsFalse(:904)—— 负例
+    verifyNoInteractions(publisher);   // 重复回调一个字节都没改
+```
+
+(`TENANT` 用该类既有的租户常量/夹具值;占位处必须换成类里真实存在的那个名字,别新造。)
+
+- [ ] **Step 2: 跑它,确认红**
+
+Run: `cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest=LsyRefundServiceTest`
+Expected: 编译失败(8 参构造器不存在)→ 补构造点后新断言 `Wanted but not invoked` 红。
+
+- [ ] **Step 3: 改实现**
+
+import `com.payment.platform.module.payment.stream.OrderEventPublisher`;字段块末尾(`private final LeshouyingProperties props;` :76 之后):
+
+```java
+    /** 退款信号发布(SSE):只在**真的写了行**之后发(`settle == null` 那种早退分支不发)。 */
+    private final OrderEventPublisher publisher;
+```
+
+四个发布点插入的都是这一行(变量名按上下文;② 用 `entity.getTenantId()`,③④ 用 `refund.getTenantId()`):
+
+```java
+        publisher.changed(OrderEventPublisher.CHANNEL_LSY_REFUND, entity.getTenantId());
+```
+
+自检:
+
+```bash
+cd java/src/main/java/com/payment/platform/module/payment/leshouying/service
+grep -c "publisher.changed" LsyRefundService.java    # 期望 4
+grep -c "refundMapper.insert\|refundMapper.update" LsyRefundService.java
+```
+第二条把全部写调用点数出来,逐个数出对应发布行(第 4 条写点的 update 是 `applyNotifyResult` 里那句带 `!= 1` 判定的)。
+
+- [ ] **Step 4: 审计(lsy_refund 表不许有漏网写点)**
+
+```bash
+cd java/src/main/java/com/payment/platform/module/payment/leshouying
+grep -rn "refundMapper\.\(insert\|update\|updateById\)(" .
+```
+每一处要么紧邻一条发布行,要么属于下面两类**有意不发**:`LsyCallbackService.recordRefundNotifyResult`(`notify_status` 列已从退款页下线,页面不展示)、`LsyReconcileScheduler.pushNextReconcileTime` 一类纯游标写。**出现第三类就停下来问。**
+
+- [ ] **Step 5: 跑它确认绿 + 变异验证**
+
+Run: `cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest=LsyRefundServiceTest`
+Expected: `Failures: 0, Errors: 0`
+
+| 变异 | 期望变红 |
+|---|---|
+| 删掉 `applyNotifyResult` 的发布行 | ④ |
+| 把发布行挪到 `refundMapper.update(patch, w) != 1` 判定**之前** | ⑤(重复回调也发了) |
+| `persistRefundSettle` 的提前返回(`settle == null`)也发 | ③ 对应的负例(改成断言 `never()`,见 Step 1 备注) |
+
+- [ ] **Step 6: 提交**
+
+```bash
+git add java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyRefundService.java \
+        java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyRefundServiceTest.java
+git commit -m "feat(订单实时): lsy 退款表四处写点发布信号"
+```
+
+---
+
+### Task 7: har 订单表(har_order)写点
+
+**Files:**
+- Modify: `har/service/HarOrderService.java`(字段块末尾 + 6 处写点)
+- Modify: `har/service/HarNotifyService.java`(字段块末尾 + 1 处写点)
+- Modify: `har/service/HarCallbackService.java`(显式构造器 +1 参 + 1 处写点)
+- Modify(仅构造点):`HarOrderServiceTest.java:54`、`HarNotifyServiceTest.java:60`
+- Test: 上面两个测试类各加断言
+
+**Interfaces:**
+- Consumes: `OrderEventPublisher.changed(String, Long)`、`CHANNEL_HAR_ORDER`
+- Produces: `HarOrderService` 3 参 → **4 参**;`HarNotifyService` 4 参 → **5 参**;`HarCallbackService` 5 参 → **6 参**(尾参一律 `OrderEventPublisher publisher`)
+
+**发布点(全部 `CHANNEL_HAR_ORDER` + `order.getTenantId()`)**:
+
+| # | 方法 | 落点 |
+|---|---|---|
+| 1 | `HarOrderService.doRefund` | :253-258 那段守卫 `if (orderMapper.update(patch, guard) == 0) { … throw … }` 之后(状态已置 REFUNDING) |
+| 2 | `HarOrderService.rollbackRefundPreempt` | `order.setRefundReason(null);`(:317,方法末)之后 —— 这里回滚成 PAID,页面必须看到 |
+| 3 | `HarOrderService.create` | `orderMapper.insert(entity);`(:417)之后 —— 新行出现 |
+| 4 | `HarOrderService.create` | `orderMapper.updateById(entity);`(:440)与 `return HarOrderVO.of(entity);` 之间 —— 补了 pay_url/上游单号,**同一次请求里的第二帧**(前端 500ms 合并会吞掉其中一帧,见 Task 9) |
+| 5 | `HarOrderService.closeRejectedOrder` | `orderMapper.update(null, w);`(:464)之后 |
+| 6 | `HarOrderService.syncFromUpstream` | 成功尾:`orderMapper.updateById(order);`(:520)与 `return HarOrderVO.of(order);` 之间 |
+| 7 | `HarNotifyService.handle…` | `orderService.reschedulePoll(order); orderMapper.updateById(order);`(:152-153)之后 |
+
+**有意不发的一处**:`syncFromUpstream` 的查询失败分支(:495 那条 `updateById`)只推 `next_query_time` 游标,订单列表页看不到这个字段 ⇒ 不发(发了就是每轮轮询推一帧,退化成噪音)。
+
+**已知测试空缺(不许忽略)**:`HarCallbackService.writeNotifyStatus` 是 `private`、只在异步回调线程里跑(`notifyEnterprise` → `callbackPool` → `doNotify` → 这里),单测要起 mock `RestTemplate` + 等异步线程。本计划**不为它写单测**,由 Task 14 的 E2E 覆盖(HAR 详情弹窗的 notify_status 列)。这是本计划里唯一一处「改了但没单测钉住」的实现 —— 评审时按此定位。
+
+- [ ] **Step 1: 写失败的测试**
+
+`HarOrderServiceTest.java`:`@Mock` 块末尾加 `@Mock private OrderEventPublisher publisher;`,:54 构造点尾追加 `, publisher`。在既有用例上加断言:
+
+```java
+    // 抄 create_persistsLocalRecordBeforeCallingUpstream(:105)
+    verify(publisher, atLeastOnce()).changed(OrderEventPublisher.CHANNEL_HAR_ORDER, TENANT);
+
+    // 抄 openRefund_preemptsToRefundingBeforeCallingUpstream(:817)—— 抢占那一笔要给页面看到
+    verify(publisher, atLeastOnce()).changed(OrderEventPublisher.CHANNEL_HAR_ORDER, TENANT);
+
+    // 抄 openRefund_preemptMissed_throwsAndDoesNotCallUpstream(:863)—— 负例
+    verify(publisher, never()).changed(any(), any());
+
+    // 抄 openRefund_upstreamNonSuccess_rollsBackToPaid(:925)—— 回滚也要发(页面从「退款中」翻回「已支付」)
+    verify(publisher, atLeastOnce()).changed(OrderEventPublisher.CHANNEL_HAR_ORDER, TENANT);
+
+    // 抄 create_upstreamNonSuccessCode_throwsAndClosesTheLocalRow(:253)
+    verify(publisher, atLeastOnce()).changed(OrderEventPublisher.CHANNEL_HAR_ORDER, TENANT);
+
+    // 抄 sync_paidStatus_mapsAllFieldsAndStopsPolling(:332)
+    verify(publisher, atLeastOnce()).changed(OrderEventPublisher.CHANNEL_HAR_ORDER, TENANT);
+
+    // 抄 sync_queryFailure_stillAdvancesNextQueryTime(:416)—— 负例:只推游标,不发帧
+    verify(publisher, never()).changed(any(), any());
+```
+
+`HarNotifyServiceTest.java`:同样加 `@Mock`、:60 构造点尾追加 `, publisher`;在 `handle_paidStatus_mapsFieldsAndDelegatesPollRescheduling`(:192)加 `verify(publisher).changed(OrderEventPublisher.CHANNEL_HAR_ORDER, TENANT);`,在 `handle_tamperedParam_returnsSignFailedAndDoesNotUpdate`(:104)加 `verify(publisher, never()).changed(any(), any());`。
+
+(`TENANT` 用每个类里既有的租户常量/夹具值,别新造。)
+
+- [ ] **Step 2: 跑它,确认红**
+
+Run: `cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest='HarOrderServiceTest+HarNotifyServiceTest'`
+Expected: 编译失败(构造器不匹配)→ 补构造点后新断言红。
+
+- [ ] **Step 3: 改实现**
+
+三个类各自 import + 字段块末尾加:
+
+```java
+    /** 订单信号发布(SSE):只在**真的写了行**之后发。 */
+    private final OrderEventPublisher publisher;
+```
+
+`HarCallbackService` 是显式构造器(:73-88):字段按现有顺序加在 `restTemplate` 之后、`callbackPool` 之前,构造器实参表**尾**追加 `OrderEventPublisher publisher` 并 `this.publisher = publisher;`。写点:
+
+```java
+            latest.setNotifyStatus(ok ? HarNotifyStatus.SUCCESS : HarNotifyStatus.FAILED);
+            orderMapper.updateById(latest);
+            publisher.changed(OrderEventPublisher.CHANNEL_HAR_ORDER, latest.getTenantId());
+```
+
+其余 7 处插的都是这一行(变量名按上下文:`order` / `entity`):
+
+```java
+        publisher.changed(OrderEventPublisher.CHANNEL_HAR_ORDER, order.getTenantId());
+```
+
+自检:
+
+```bash
+cd java/src/main/java/com/payment/platform/module/payment/har/service
+grep -c "publisher.changed" HarOrderService.java     # 期望 6
+grep -c "publisher.changed" HarNotifyService.java    # 期望 1
+grep -c "publisher.changed" HarCallbackService.java  # 期望 1
+```
+
+- [ ] **Step 4: 审计(har_order 表)**
+
+```bash
+cd java/src/main/java/com/payment/platform/module/payment/har
+grep -rn "orderMapper\.\(insert\|update\|updateById\)(" .
+```
+每处要么紧邻发布行,要么是上面点名的「有意不发」那一处(`syncFromUpstream` 失败分支的游标写)。**出现第三类就停下来问。**
+
+- [ ] **Step 5: 跑它确认绿 + 全量回归**
+
+Run: `cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest='HarOrderServiceTest+HarNotifyServiceTest'`
+Expected: `Failures: 0, Errors: 0`
+
+Run: `cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test`
+Expected: 本次 stdout 汇总行 `Failures: 0, Errors: 0` —— **后端到此为止,Task 14 之前不再改 Java 代码**
+
+- [ ] **Step 6: 变异验证**
+
+| 变异 | 期望变红 |
+|---|---|
+| 删掉 `doRefund` 的发布行 | `openRefund_preemptsToRefunding…` 的断言 |
+| 把 `syncFromUpstream` 失败分支也加上发布 | `sync_queryFailure_stillAdvancesNextQueryTime` 的 `never()` |
+| 删掉 `rollbackRefundPreempt` 的发布行 | `openRefund_upstreamNonSuccess_rollsBackToPaid` 的断言 |
+
+- [ ] **Step 7: 提交**
+
+```bash
+git add java/src/main/java/com/payment/platform/module/payment/har/service/HarOrderService.java \
+        java/src/main/java/com/payment/platform/module/payment/har/service/HarNotifyService.java \
+        java/src/main/java/com/payment/platform/module/payment/har/service/HarCallbackService.java \
+        java/src/test/java/com/payment/platform/module/payment/har/service/HarOrderServiceTest.java \
+        java/src/test/java/com/payment/platform/module/payment/har/service/HarNotifyServiceTest.java
+git commit -m "feat(订单实时): har 订单表八处写点发布信号(含回滚与回调状态)"
+```
+
+### Task 8: 前端 —— orderStream 工厂(纯模块 + node 执行级验证)
+
+**Files:**
+- Create: `frontend/scripts/verify-order-stream.mjs`
+- Create: `frontend/src/utils/orderStream.ts`(**纯模块:零 import**,依赖全部注入 —— 这样 node 脚本能原样打包跑)
+- 需要 `frontend/scripts/` 目录(现在**不存在**,Write 会自动建)
+
+**Interfaces:**
+- Consumes: 无
+- Produces(Task 9/11/12/13 用):
+  - `createOrderStream(deps: OrderStreamDeps): OrderStream`
+  - `OrderStream.subscribe(channel, handler): () => void` / `.onResync(handler): () => void` / `.stop(): void`
+  - `OrderStreamDeps = { url: () => string; createEventSource; now?; setTimer?; clearTimer?; backoffBaseMs?; backoffMaxMs?; watchdogMs? }`
+  - 线上契约:事件名 `changed`(数据 `{"channel":"…"}`)与 `ping`;三个通道名 `lsy_order` / `lsy_refund` / `har_order`(与后端 `OrderEventPublisher` 逐字一致)
+
+**量具说明(为什么是 node 脚本)**:`frontend/package.json` 里没有 vitest/jest —— 本仓前端零测试框架。所以用**仓库现成的** esbuild(`node_modules/esbuild` 0.25.12,node v22.22.3)把真 TS 打成一个临时 ESM,配假 EventSource / 假时钟在 node 里跑断言。这不是「近似」:跑的是**同一份源文件**,只是把浏览器 API 换成注入的空壳。
+
+- [ ] **Step 1: 写失败的验证脚本**
+
+创建 `frontend/scripts/verify-order-stream.mjs`:
+
+```javascript
+#!/usr/bin/env node
+/**
+ * 订单实时流(src/utils/orderStream.ts)的执行级验证。
+ * 用法:cd frontend && node scripts/verify-order-stream.mjs
+ *
+ * 断言失败打印 FAIL 并以非 0 退出;每条 PASS 都打名字(读数要看得见)。
+ */
+import { build } from "esbuild";
+import assert from "node:assert/strict";
+import path from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import fs from "node:fs/promises";
+import os from "node:os";
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const root = path.resolve(here, "..");
+const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "verify-order-stream-"));
+const bundle = path.join(tmp, "orderStream.mjs");
+
+await build({
+  entryPoints: [path.join(root, "src/utils/orderStream.ts")],
+  outfile: bundle,
+  bundle: true,
+  format: "esm",
+  platform: "neutral",
+  logLevel: "error"
+});
+
+const { createOrderStream } = await import(pathToFileURL(bundle).href);
+
+// ─────────────────────────── 假件 ───────────────────────────
+
+class FakeEventSource {
+  constructor(url, at) {
+    this.url = url;
+    this.at = at;              // 建连时刻(假时钟读数)
+    this.closed = false;
+    this.listeners = new Map();
+  }
+  addEventListener(type, fn) {
+    if (!this.listeners.has(type)) this.listeners.set(type, []);
+    this.listeners.get(type).push(fn);
+  }
+  close() {
+    this.closed = true;
+  }
+  fire(type, payload = {}) {
+    for (const fn of this.listeners.get(type) || []) fn(payload);
+  }
+}
+
+function fakeClock() {
+  let t = 0;
+  const jobs = [];
+  let nextId = 1;
+  return {
+    now: () => t,
+    setTimer: (fn, ms) => {
+      const id = nextId++;
+      jobs.push({ id, at: t + ms, fn });
+      return id;
+    },
+    clearTimer: id => {
+      const i = jobs.findIndex(j => j.id === id);
+      if (i >= 0) jobs.splice(i, 1);
+    },
+    advance(ms) {
+      const end = t + ms;
+      for (;;) {
+        const due = jobs.filter(j => j.at <= end).sort((a, b) => a.at - b.at)[0];
+        if (!due) break;
+        jobs.splice(jobs.indexOf(due), 1);
+        t = due.at;
+        due.fn();
+      }
+      t = end;
+    }
+  };
+}
+
+function harness() {
+  const clock = fakeClock();
+  const built = [];
+  const state = { urlCalls: 0 };
+  const stream = createOrderStream({
+    url: () => {
+      state.urlCalls += 1;
+      return `http://x/api/v1/payment/stream/orders?token=T${state.urlCalls}`;
+    },
+    createEventSource: url => {
+      const es = new FakeEventSource(url, clock.now());
+      built.push(es);
+      return es;
+    },
+    now: clock.now,
+    setTimer: clock.setTimer,
+    clearTimer: clock.clearTimer,
+    backoffBaseMs: 1000,
+    backoffMaxMs: 30000,
+    watchdogMs: 50000
+  });
+  return { clock, built, state, stream };
+}
+
+let failed = 0;
+function check(name, fn) {
+  try {
+    fn();
+    console.log("  PASS  " + name);
+  } catch (e) {
+    failed += 1;
+    console.error("  FAIL  " + name + "\n        " + (e && e.message));
+  }
+}
+
+// ─────────────────────────── 断言 ───────────────────────────
+
+check("subscribe 建一条连接,URL 来自 url()", () => {
+  const { built, stream } = harness();
+  const off = stream.subscribe("lsy_order", () => {});
+  assert.equal(built.length, 1);
+  assert.equal(built[0].url, "http://x/api/v1/payment/stream/orders?token=T1");
+  off();
+});
+
+check("changed 帧只触发该通道的订阅者", () => {
+  const { built, stream } = harness();
+  let order = 0;
+  let refund = 0;
+  const off1 = stream.subscribe("lsy_order", () => { order += 1; });
+  const off2 = stream.subscribe("lsy_refund", () => { refund += 1; });
+
+  built[0].fire("changed", { data: JSON.stringify({ channel: "lsy_order" }) });
+  assert.equal(order, 1);
+  assert.equal(refund, 0, "别的通道的帧不许触发本通道");
+
+  built[0].fire("changed", { data: JSON.stringify({ channel: "har_order" }) });
+  assert.equal(order, 1);
+  assert.equal(refund, 0, "没人订阅的通道:静默丢弃");
+
+  off1();
+  off2();
+});
+
+check("坏帧不抛异常、不影响后续帧", () => {
+  const { built, stream } = harness();
+  let n = 0;
+  const off = stream.subscribe("lsy_order", () => { n += 1; });
+
+  built[0].fire("changed", { data: "{oops" });
+  built[0].fire("changed", {});
+  built[0].fire("changed", { data: JSON.stringify({}) });
+  built[0].fire("changed", { data: JSON.stringify({ channel: "lsy_order" }) });
+
+  assert.equal(n, 1, "只有最后那条合法帧该生效");
+  off();
+});
+
+check("首次 open 不补拉;重连后的 open 补拉一次", () => {
+  const { clock, built, stream } = harness();
+  let resync = 0;
+  const offR = stream.onResync(() => { resync += 1; });
+  const off = stream.subscribe("lsy_order", () => {});
+
+  built[0].fire("open", {});
+  assert.equal(resync, 0, "首次连接时页面自己正在加载,不该再多拉一次");
+
+  built[0].fire("error", {});
+  clock.advance(1000);
+  assert.equal(built.length, 2);
+  built[1].fire("open", {});
+  assert.equal(resync, 1, "断线期间漏掉的帧必须靠重连补拉兜回来");
+  assert.equal(built[0].closed, true, "旧连接要显式 close(浏览器自带重连与我们的退避不能两条腿跑)");
+
+  off();
+  offR();
+});
+
+check("退避 1s→2s→4s→…→30s 封顶", () => {
+  const { clock, built, stream } = harness();
+  const off = stream.subscribe("lsy_order", () => {});
+
+  let expected = 1;
+  for (const delay of [1000, 2000, 4000, 8000, 16000, 30000, 30000]) {
+    built[built.length - 1].fire("error", {});
+    clock.advance(delay - 1);
+    assert.equal(built.length, expected, `${delay}ms 还没到就重连了`);
+    clock.advance(1);
+    assert.equal(built.length, expected + 1, `${delay}ms 到了还没重连`);
+    expected += 1;
+  }
+  off();
+});
+
+check("每次重连都重新调 url()(令牌换新)", () => {
+  const { clock, built, state, stream } = harness();
+  const off = stream.subscribe("lsy_order", () => {});
+  built[0].fire("open", {});
+  assert.equal(state.urlCalls, 1);
+
+  built[0].fire("error", {});
+  clock.advance(1000);
+  assert.equal(state.urlCalls, 2, "重连必须重新拼 URL —— 旧令牌可能已经失效");
+  assert.notEqual(built[0].url, built[1].url);
+  off();
+});
+
+check("50s 没有任何帧(ping 也算)→ 弃掉重建", () => {
+  const { clock, built, stream } = harness();
+  const off = stream.subscribe("lsy_order", () => {});
+  built[0].fire("open", {});
+
+  clock.advance(20000);
+  built[0].fire("ping", {});          // 服务端 20s 一个 ping:看门狗被喂饱
+
+  clock.advance(49999);
+  assert.equal(built[0].closed, false, "ping 之后不到 50s 不该重建");
+  clock.advance(1);
+  assert.equal(built[0].closed, true, "50s 没有帧 = 半死连接(TCP 还在、对端不推了),必须重建");
+  clock.advance(1000);
+  assert.equal(built.length, 2);
+  off();
+});
+
+check("stop() 关连接、清定时器,此后不再重连", () => {
+  const { clock, built, stream } = harness();
+  const off = stream.subscribe("lsy_order", () => {});
+  built[0].fire("open", {});
+
+  stream.stop();
+
+  assert.equal(built[0].closed, true);
+  clock.advance(600000);
+  assert.equal(built.length, 1, "stop 之后不许再有任何新连接(登出后 token 已清,重连只会被 403 挡回)");
+  off();
+});
+
+check("最后一个订阅者退订 → 连接关闭", () => {
+  const { built, stream } = harness();
+  const off1 = stream.subscribe("lsy_order", () => {});
+  const off2 = stream.subscribe("lsy_refund", () => {});
+
+  off1();
+  assert.equal(built[0].closed, false, "还有订阅者时不许断");
+  off2();
+  assert.equal(built[0].closed, true);
+});
+
+check("三个通道名的线上契约(与后端 OrderEventPublisher 逐字一致)", () => {
+  const CHANNELS = ["lsy_order", "lsy_refund", "har_order"];
+  const { built, stream } = harness();
+  const hits = { lsy_order: 0, lsy_refund: 0, har_order: 0 };
+  const offs = CHANNELS.map(c => stream.subscribe(c, () => { hits[c] += 1; }));
+
+  for (const c of CHANNELS) {
+    built[0].fire("changed", { data: JSON.stringify({ channel: c }) });
+  }
+
+  assert.deepEqual(hits, { lsy_order: 1, lsy_refund: 1, har_order: 1 });
+  offs.forEach(off => off());
+});
+
+console.log(failed === 0 ? "\n全部通过" : `\n${failed} 条失败`);
+if (failed > 0) process.exit(1);
+```
+
+- [ ] **Step 2: 跑它,确认红**
+
+Run: `cd frontend && node scripts/verify-order-stream.mjs`
+Expected: esbuild 报错 `Could not resolve "…/src/utils/orderStream.ts"`(模块还不存在),退出码非 0。
+
+- [ ] **Step 3: 写实现**
+
+创建 `frontend/src/utils/orderStream.ts`:
+
+```typescript
+/**
+ * 订单实时信号(SSE)—— 与后端 `/api/v1/payment/stream/orders` 的单条长连接。
+ *
+ * 三条纪律(每条都有对应断言,见 scripts/verify-order-stream.mjs):
+ * 1. **零用户可见状态**:不显示连接状态、不弹提示、断线也不跳登录页。
+ * 2. **url() 每次(重)连现取**:令牌会换(滑动过期、重新登录),只在建连时算一次
+ *    等于拿旧令牌反复撞墙。
+ * 3. **重连成功后补拉一次**(resync):断线期间漏掉的帧靠它兜;首次 open 不补拉
+ *    ——那会儿页面自己正在加载。
+ *
+ * 本模块是**纯的**(零 import、依赖全注入),所以 node 脚本能原样打包它来跑;
+ * 应用级单例在 `@/utils/orderStreamInstance`。
+ */
+
+export interface EventSourceLike {
+  addEventListener(type: string, fn: (event: any) => void): void;
+  close(): void;
+}
+
+export interface OrderStreamDeps {
+  /** 完整 URL(`?token=` 由调用方拼好)。**每次(重)连都会重新调**。 */
+  url: () => string;
+  createEventSource: (url: string) => EventSourceLike;
+  now?: () => number;
+  setTimer?: (fn: () => void, ms: number) => number;
+  clearTimer?: (id: number) => void;
+  /** 退避起点,默认 1000ms。 */
+  backoffBaseMs?: number;
+  /** 退避上限,默认 30000ms。 */
+  backoffMaxMs?: number;
+  /** 看门狗阈值,默认 50000ms(服务端 20s 一个 ping:两个没来就重建)。 */
+  watchdogMs?: number;
+}
+
+export interface OrderStream {
+  /** 订阅一个通道;返回退订函数。第一个订阅者触发建连,最后一个退订触发断连。 */
+  subscribe(channel: string, handler: () => void): () => void;
+  /** 重连成功(非首次)时回调一次,用来补拉。 */
+  onResync(handler: () => void): () => void;
+  /** 断连且不再重连(登出时调用)。 */
+  stop(): void;
+  /** 测试用:当前有没有连接。**页面上不许显示**。 */
+  isRunning(): boolean;
+}
+
+const DEFAULT_BACKOFF_BASE_MS = 1000;
+const DEFAULT_BACKOFF_MAX_MS = 30000;
+const DEFAULT_WATCHDOG_MS = 50000;
+
+export function createOrderStream(deps: OrderStreamDeps): OrderStream {
+  const backoffBase = deps.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS;
+  const backoffMax = deps.backoffMaxMs ?? DEFAULT_BACKOFF_MAX_MS;
+  const watchdogMs = deps.watchdogMs ?? DEFAULT_WATCHDOG_MS;
+  const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms) as unknown as number);
+  const clearTimer = deps.clearTimer ?? (id => clearTimeout(id as unknown as ReturnType<typeof setTimeout>));
+
+  const handlers = new Map<string, Set<() => void>>();
+  const resyncHandlers = new Set<() => void>();
+
+  let source: EventSourceLike | null = null;
+  let reconnectTimer: number | null = null;
+  let watchdogTimer: number | null = null;
+  let backoff = backoffBase;
+  let everConnected = false;
+
+  function clearTimers(): void {
+    if (reconnectTimer !== null) {
+      clearTimer(reconnectTimer);
+      reconnectTimer = null;
+    }
+    if (watchdogTimer !== null) {
+      clearTimer(watchdogTimer);
+      watchdogTimer = null;
+    }
+  }
+
+  function closeSource(): void {
+    if (source) {
+      try {
+        source.close();
+      } catch {
+        /* 关不上就算了:连接已经死了 */
+      }
+      source = null;
+    }
+  }
+
+  function armWatchdog(): void {
+    if (watchdogTimer !== null) {
+      clearTimer(watchdogTimer);
+    }
+    // 半死连接(TCP 还在、对端不推了)不会自己报 error,只能靠「多久没听到声音」判
+    watchdogTimer = setTimer(dropAndReconnect, watchdogMs);
+  }
+
+  function dropAndReconnect(): void {
+    clearTimers();
+    closeSource();
+    scheduleReconnect();
+  }
+
+  function scheduleReconnect(): void {
+    if (handlers.size === 0) {
+      return; // 没人订阅了就别连(登出后就是这个状态)
+    }
+    const delay = backoff;
+    backoff = Math.min(backoff * 2, backoffMax);
+    reconnectTimer = setTimer(connect, delay);
+  }
+
+  function connect(): void {
+    reconnectTimer = null;
+    const es = deps.createEventSource(deps.url()); // ← 每次(重)连都现取 URL
+    source = es;
+
+    es.addEventListener("open", () => {
+      backoff = backoffBase; // 连上了就重置退避,别把上次的惩罚带进下一次断线
+      armWatchdog();
+      if (everConnected) {
+        for (const fn of resyncHandlers) {
+          fn();
+        }
+      }
+      everConnected = true;
+    });
+
+    es.addEventListener("changed", (event: any) => {
+      armWatchdog();
+      let channel: unknown;
+      try {
+        channel = JSON.parse(event?.data ?? "{}")?.channel;
+      } catch {
+        return; // 坏帧静默丢弃
+      }
+      if (typeof channel !== "string") {
+        return;
+      }
+      const set = handlers.get(channel);
+      if (set) {
+        for (const fn of set) {
+          fn();
+        }
+      }
+    });
+
+    es.addEventListener("ping", () => {
+      armWatchdog();
+    });
+
+    es.addEventListener("error", () => {
+      // 不区分「网络断」与「被 401 挡回」:都是退避重连,永远不弹窗、不跳登录页
+      if (source === es) {
+        dropAndReconnect();
+      }
+    });
+  }
+
+  const stream: OrderStream = {
+    subscribe(channel, handler) {
+      let set = handlers.get(channel);
+      if (!set) {
+        set = new Set();
+        handlers.set(channel, set);
+      }
+      set.add(handler);
+      if (source === null && reconnectTimer === null) {
+        connect();
+      }
+      return () => {
+        const current = handlers.get(channel);
+        current?.delete(handler);
+        if (current && current.size === 0) {
+          handlers.delete(channel);
+        }
+        if (handlers.size === 0) {
+          stream.stop();
+        }
+      };
+    },
+    onResync(handler) {
+      resyncHandlers.add(handler);
+      return () => {
+        resyncHandlers.delete(handler);
+      };
+    },
+    stop() {
+      clearTimers();
+      closeSource();
+      backoff = backoffBase;
+      everConnected = false;
+    },
+    isRunning() {
+      return source !== null;
+    }
+  };
+
+  return stream;
+}
+```
+
+- [ ] **Step 4: 跑它,确认绿**
+
+Run: `cd frontend && node scripts/verify-order-stream.mjs`
+Expected: 10 条 `PASS` + `全部通过`,退出码 0
+
+- [ ] **Step 5: 变异验证(两向)**
+
+| 变异 | 期望变红 |
+|---|---|
+| `connect()` 里把 `deps.url()` 换成建流时缓存的一次性 URL | `每次重连都重新调 url()` |
+| 删掉 `es.addEventListener("ping", armWatchdog)` | `50s 没有任何帧…`(20s 的 ping 不再喂狗) |
+| `open` 里去掉 `backoff = backoffBase` | 退避序列用例(第二轮的间隔会从 2000 起) |
+| 去掉 `everConnected` 判定(首次 open 也 resync) | `首次 open 不补拉…` |
+| `stop()` 里不调 `closeSource()` | `stop() 关连接…` |
+| `error` 分支改成 `close+connect`(不退避) | 退避序列用例 |
+
+任一变异没红 ⇒ 停手修断言。
+
+- [ ] **Step 6: 提交**
+
+```bash
+git add frontend/scripts/verify-order-stream.mjs frontend/src/utils/orderStream.ts
+git commit -m "feat(订单实时): 前端 SSE 单例工厂(退避重连 + 看门狗 + 重连补拉)"
+```
+
+### Task 9: 前端 —— 应用单例 + useLiveRefetch(挂起/补刷/合并)
+
+**Files:**
+- Create: `frontend/src/utils/orderStreamInstance.ts`(应用单例:真 EventSource + 现取令牌 URL)
+- Create: `frontend/src/composables/useLiveRefetch.ts`
+- Create: `frontend/scripts/verify-live-refetch.mjs`
+- Modify: `frontend/src/composables/index.ts`(加导出)
+
+**Interfaces:**
+- Consumes: `createOrderStream` / `OrderStream`(Task 8)、`Auth.getAccessToken`(既有)
+- Produces(Task 10/11/12/13 用):
+  - `useLiveRefetch(channels: string[], refetch: () => unknown, options?: { busy?: Ref<boolean>; debounceMs?: number; stream?: OrderStream }): () => void`
+  - `orderStream(): OrderStream`(懒建单例)、`stopOrderStream(): void`(登出停流)
+
+**语义(四条,逐条有断言)**:
+1. 收到本通道帧 → **合并 500ms** 后调 `refetch()`(同一次写入的多帧只拉一次)
+2. `busy === true` 期间任何信号(含重连补拉)**只记 pending、不刷**
+3. `busy` 由 true 翻 false 且期间有 pending → **立刻静默补刷一次**(不等下一个信号)
+4. 默认不额外刷 —— `busy` 翻转但没有 pending 时什么都不做(防「每次关弹窗都白拉一次」)
+5. `refetch` 抛错/reject → **吞掉**(后台刷新失败不许弹窗、不许打断用户)
+6. 退订/卸载时清定时器、退订全部通道(最后一个退订会让 Task 8 的连接自动关掉)
+
+- [ ] **Step 1: 写失败的验证脚本**
+
+创建 `frontend/scripts/verify-live-refetch.mjs`:
+
+```javascript
+#!/usr/bin/env node
+/**
+ * useLiveRefetch(挂起/补刷/合并)+ 应用单例(URL 拼装、登出停流)的执行级验证。
+ * 用法:cd frontend && node scripts/verify-live-refetch.mjs
+ *
+ * 量具:esbuild 把真 TS + 真 vue 打成一个临时 bundle(vue 必须**打进同一份**,
+ * 否则脚本里的 ref 与 bundle 里的 watch 分属两个 vue 实例、依赖收集是断的)。
+ */
+import { build } from "esbuild";
+import assert from "node:assert/strict";
+import path from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import fs from "node:fs/promises";
+import os from "node:os";
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const root = path.resolve(here, "..");
+const modulePath = path.join(root, "src/composables/useLiveRefetch.ts").replaceAll("\\", "/");
+
+// Auth 链会读 localStorage/sessionStorage:装壳(模块 import 期不读,保险起见先装)
+const makeStore = () => {
+  const map = new Map();
+  return {
+    getItem: k => (map.has(k) ? map.get(k) : null),
+    setItem: (k, v) => map.set(k, String(v)),
+    removeItem: k => map.delete(k),
+    clear: () => map.clear()
+  };
+};
+const localStore = makeStore();
+globalThis.localStorage = localStore;
+globalThis.sessionStorage = makeStore();
+
+// 单例那条腿要用真 `new EventSource(...)`:换成假件,把 URL 抓下来
+const eventSources = [];
+globalThis.EventSource = class {
+  constructor(url) {
+    this.url = url;
+    this.closed = false;
+    this.listeners = new Map();
+    eventSources.push(this);
+  }
+  addEventListener(type, fn) {
+    if (!this.listeners.has(type)) this.listeners.set(type, []);
+    this.listeners.get(type).push(fn);
+  }
+  close() {
+    this.closed = true;
+  }
+};
+
+const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "verify-live-refetch-"));
+const bundle = path.join(tmp, "bundle.mjs");
+
+await build({
+  stdin: {
+    contents: `
+      export * from ${JSON.stringify(modulePath)};
+      export { ref, nextTick } from "vue";
+    `,
+    resolveDir: root, // 从 frontend/ 解析:vue 与 "@/..." 都从这里走
+    sourcefile: "verify-entry.mjs",
+    loader: "js"
+  },
+  outfile: bundle,
+  bundle: true,
+  format: "esm",
+  platform: "neutral",
+  target: "node22",
+  logLevel: "error",
+  alias: { "@": path.join(root, "src") },
+  define: {
+    "import.meta.env.VITE_APP_BASE_API": JSON.stringify("/api/v1"),
+    __VUE_OPTIONS_API__: "false",
+    __VUE_PROD_DEVTOOLS__: "false",
+    __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: "false"
+  }
+});
+
+const { useLiveRefetch, orderStream, stopOrderStream, ref, nextTick } = await import(pathToFileURL(bundle).href);
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+function fakeStream() {
+  const subs = new Map();
+  const resyncs = new Set();
+  let unsubscribed = 0;
+  return {
+    subscribe(channel, fn) {
+      if (!subs.has(channel)) subs.set(channel, new Set());
+      subs.get(channel).add(fn);
+      return () => {
+        subs.get(channel)?.delete(fn);
+        unsubscribed += 1;
+      };
+    },
+    onResync(fn) {
+      resyncs.add(fn);
+      return () => resyncs.delete(fn);
+    },
+    stop() {},
+    isRunning: () => true,
+    emit(channel) {
+      for (const fn of subs.get(channel) || []) fn();
+    },
+    fireResync() {
+      for (const fn of resyncs) fn();
+    },
+    get channels() {
+      return [...subs.keys()].sort();
+    },
+    get unsubscribed() {
+      return unsubscribed;
+    }
+  };
+}
+
+let failed = 0;
+async function check(name, fn) {
+  try {
+    await fn();
+    console.log("  PASS  " + name);
+  } catch (e) {
+    failed += 1;
+    console.error("  FAIL  " + name + "\n        " + (e && e.message));
+  }
+}
+
+const OPTS = { debounceMs: 5 };
+
+await check("订阅给定的三个通道", async () => {
+  const stream = fakeStream();
+  let calls = 0;
+  const dispose = useLiveRefetch(["lsy_order", "lsy_refund", "har_order"], () => { calls += 1; }, { ...OPTS, stream });
+
+  assert.deepEqual(stream.channels, ["har_order", "lsy_order", "lsy_refund"]);
+  dispose();
+});
+
+await check("本通道的帧 → 合并窗口后刷一次;别的通道不触发", async () => {
+  const stream = fakeStream();
+  let calls = 0;
+  const dispose = useLiveRefetch(["lsy_order"], () => { calls += 1; }, { ...OPTS, stream });
+
+  stream.emit("lsy_refund");
+  await sleep(20);
+  assert.equal(calls, 0, "没订阅的通道不许刷");
+
+  stream.emit("lsy_order");
+  assert.equal(calls, 0, "合并窗口内不许同步刷(同一次写入会来好几帧)");
+  await sleep(20);
+  assert.equal(calls, 1);
+
+  dispose();
+});
+
+await check("窗口内多帧(含重连补拉)只刷一次", async () => {
+  const stream = fakeStream();
+  let calls = 0;
+  const dispose = useLiveRefetch(["lsy_order"], () => { calls += 1; }, { ...OPTS, stream });
+
+  stream.emit("lsy_order");
+  stream.emit("lsy_order");
+  stream.fireResync();
+  await sleep(20);
+
+  assert.equal(calls, 1, "一次写入的多帧 + 一次重连补拉,合并成一次重拉");
+  dispose();
+});
+
+await check("busy 期间挂起,回安全态立刻补刷一次", async () => {
+  const stream = fakeStream();
+  const busy = ref(false);
+  let calls = 0;
+  const dispose = useLiveRefetch(["lsy_order"], () => { calls += 1; }, { ...OPTS, stream, busy });
+
+  busy.value = true;
+  await nextTick();
+
+  stream.emit("lsy_order");
+  stream.emit("lsy_order");
+  await sleep(20);
+  assert.equal(calls, 0, "用户正在勾选/弹窗里操作,列表不许在脚下跳");
+
+  busy.value = false;
+  await nextTick();
+  assert.equal(calls, 1, "回到安全态立刻补刷(不等下一个信号)");
+
+  await sleep(20);
+  assert.equal(calls, 1, "补刷只做一次(pending 要清掉)");
+  dispose();
+});
+
+await check("busy 翻转但期间没有信号 → 一次都不刷", async () => {
+  const stream = fakeStream();
+  const busy = ref(false);
+  let calls = 0;
+  const dispose = useLiveRefetch(["lsy_order"], () => { calls += 1; }, { ...OPTS, stream, busy });
+
+  busy.value = true;
+  await nextTick();
+  busy.value = false;
+  await nextTick();
+  await sleep(20);
+
+  assert.equal(calls, 0, "没漏东西就白拉一次 = 每次关弹窗都打一次接口");
+  dispose();
+});
+
+await check("busy 期间的重连补拉也挂起", async () => {
+  const stream = fakeStream();
+  const busy = ref(true);
+  let calls = 0;
+  const dispose = useLiveRefetch(["lsy_order"], () => { calls += 1; }, { ...OPTS, stream, busy });
+  await nextTick();
+
+  stream.fireResync();
+  await sleep(20);
+  assert.equal(calls, 0);
+
+  busy.value = false;
+  await nextTick();
+  assert.equal(calls, 1);
+  dispose();
+});
+
+await check("refetch 抛错 / reject 都不冒泡,后续帧仍能刷", async () => {
+  const stream = fakeStream();
+  let calls = 0;
+  const dispose = useLiveRefetch(
+    ["lsy_order"],
+    () => {
+      calls += 1;
+      if (calls === 1) throw new Error("同步炸");
+      if (calls === 2) return Promise.reject(new Error("异步炸"));
+      return Promise.resolve();
+    },
+    { ...OPTS, stream }
+  );
+
+  stream.emit("lsy_order");
+  await sleep(20);
+  stream.emit("lsy_order");
+  await sleep(20);
+  stream.emit("lsy_order");
+  await sleep(20);
+
+  assert.equal(calls, 3, "三次都该真的调下去(异常被吞掉,不影响下一次)");
+  dispose();
+});
+
+await check("dispose:退订全部通道、清掉未到期的合并、不再刷", async () => {
+  const stream = fakeStream();
+  let calls = 0;
+  const dispose = useLiveRefetch(["lsy_order", "lsy_refund"], () => { calls += 1; }, { ...OPTS, stream });
+
+  stream.emit("lsy_order");
+  dispose();
+  assert.equal(stream.unsubscribed, 2, "两个通道都要退订");
+
+  await sleep(20);
+  assert.equal(calls, 0, "dispose 时未到期的合并必须被清掉(页面已经卸载了)");
+
+  stream.emit("lsy_order");
+  await sleep(20);
+  assert.equal(calls, 0);
+});
+
+await check("单例:URL 现取令牌拼装 + 登出停流", async () => {
+  // Storage.get 会 JSON.parse,所以按 Storage.set 的写法存(带引号的 JSON 串)
+  localStore.setItem("remember_me", "true");
+  localStore.setItem("access_token", JSON.stringify("tok-123"));
+
+  assert.equal(orderStream(), orderStream(), "必须是同一个实例");
+
+  const off = orderStream().subscribe("lsy_order", () => {});
+  assert.equal(eventSources.length, 1);
+  assert.equal(eventSources[0].url, "/api/v1/payment/stream/orders?token=tok-123");
+
+  stopOrderStream();
+  assert.equal(eventSources[0].closed, true, "登出停流必须真的关连接(token 已清,重连只会被 403 挡回)");
+
+  off();
+});
+
+console.log(failed === 0 ? "\n全部通过" : `\n${failed} 条失败`);
+if (failed > 0) process.exit(1);
+```
+
+- [ ] **Step 2: 跑它,确认红**
+
+Run: `cd frontend && node scripts/verify-live-refetch.mjs`
+Expected: esbuild 报错 `Could not resolve "…/src/composables/useLiveRefetch.ts"`(模块还不存在),退出码非 0。
+
+- [ ] **Step 3: 写实现**
+
+创建 `frontend/src/utils/orderStreamInstance.ts`:
+
+```typescript
+import { Auth } from "@/utils/auth";
+import { createOrderStream, type OrderStream } from "@/utils/orderStream";
+
+/**
+ * 应用级单例。**懒创建**:导入本模块不产生任何网络行为,第一个订阅者才建连接。
+ *
+ * 令牌**每次(重)连现取** —— 它是滑动过期的,且用户可能重新登录;
+ * 只在建连时算一次,等于拿旧令牌反复撞墙(而重连永远不成功是「页面不实时」里最难查的一种)。
+ */
+let instance: OrderStream | null = null;
+
+export function orderStream(): OrderStream {
+  if (!instance) {
+    instance = createOrderStream({
+      url: () =>
+        `${import.meta.env.VITE_APP_BASE_API}/payment/stream/orders?token=${encodeURIComponent(
+          Auth.getAccessToken() || ""
+        )}`,
+      createEventSource: url => new EventSource(url)
+    });
+  }
+  return instance;
+}
+
+/** 登出时调用:关连接、清定时器,此后不再重连。 */
+export function stopOrderStream(): void {
+  instance?.stop();
+}
+```
+
+创建 `frontend/src/composables/useLiveRefetch.ts`:
+
+```typescript
+import { getCurrentInstance, onBeforeUnmount, watch, type Ref } from "vue";
+import type { OrderStream } from "@/utils/orderStream";
+import { orderStream } from "@/utils/orderStreamInstance";
+
+export interface LiveRefetchOptions {
+  /** 操作中(勾选/弹窗/提交):true 期间收到信号只记 pending,回到 false 立刻补刷一次 */
+  busy?: Ref<boolean>;
+  /** 信号合并窗口(毫秒)。同一次写入会来好几帧,默认 500ms 内只拉一次 */
+  debounceMs?: number;
+  /** 注入用(验证脚本);不传就用应用单例 */
+  stream?: OrderStream;
+}
+
+const DEFAULT_DEBOUNCE_MS = 500;
+
+/**
+ * 把「后端说有变化」变成「重拉本页列表」。用户零感知:没有状态、没有提示、没有 toast。
+ *
+ * - 后台刷新失败**静默**(调用方的请求要带 `silent: true`,见 Task 10)
+ * - `busy` 期间挂起(用户正在勾选/弹窗里操作,列表不许在脚下跳),回安全态补刷一次
+ * - 重连补拉走同一条路(断线期间漏掉的帧靠它兜)
+ * - 组件卸载自动退订;最后一个订阅者退订时连接自动关(Task 8 的引用计数)
+ */
+export function useLiveRefetch(
+  channels: string[],
+  refetch: () => unknown,
+  options: LiveRefetchOptions = {}
+): () => void {
+  const stream = options.stream ?? orderStream();
+  const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
+  const busy = options.busy;
+
+  let pending = false;
+  let timer: ReturnType<typeof setTimeout> | null = null;
+  let disposed = false;
+
+  const run = (): void => {
+    if (disposed) return;
+    timer = null;
+    try {
+      const result = refetch();
+      if (result && typeof (result as Promise<unknown>).then === "function") {
+        (result as Promise<unknown>).catch(() => {
+          /* 后台刷新失败:静默。用户没主动点任何东西,弹窗只会让人困惑 */
+        });
+      }
+    } catch {
+      /* 同上 */
+    }
+  };
+
+  const schedule = (): void => {
+    if (disposed) return;
+    if (busy?.value) {
+      pending = true;
+      return;
+    }
+    if (timer !== null) return; // 合并窗口还没过:这一帧并进那一次
+    timer = setTimeout(run, debounceMs);
+  };
+
+  const offs = channels.map(channel => stream.subscribe(channel, schedule));
+  const offResync = stream.onResync(schedule);
+
+  let stopWatch = (): void => {};
+  if (busy) {
+    stopWatch = watch(busy, isBusy => {
+      if (!isBusy && pending) {
+        pending = false;
+        if (timer !== null) {
+          clearTimeout(timer);
+          timer = null;
+        }
+        run();
+      }
+    });
+  }
+
+  const dispose = (): void => {
+    if (disposed) return;
+    disposed = true;
+    if (timer !== null) {
+      clearTimeout(timer);
+      timer = null;
+    }
+    offs.forEach(off => off());
+    offResync();
+    stopWatch();
+  };
+
+  if (getCurrentInstance()) {
+    onBeforeUnmount(dispose);
+  }
+  return dispose;
+}
+```
+
+`frontend/src/composables/index.ts` 追加:
+
+```typescript
+// 订单实时刷新
+export { useLiveRefetch } from "./useLiveRefetch";
+export type { LiveRefetchOptions } from "./useLiveRefetch";
+```
+
+- [ ] **Step 4: 跑它,确认绿**
+
+Run: `cd frontend && node scripts/verify-live-refetch.mjs`
+Expected: 9 条 `PASS` + `全部通过`,退出码 0
+
+- [ ] **Step 5: 变异验证**
+
+| 变异 | 期望变红 |
+|---|---|
+| `schedule` 里把 `busy?.value` 判断删掉(不挂起) | `busy 期间挂起…` |
+| `watch` 回调里改成 `if (!isBusy) run()`(不看 pending) | `busy 翻转但期间没有信号…` |
+| `watch` 回调里去掉 `pending = false` | `busy 期间挂起…` 的「补刷只做一次」 |
+| 去掉 `.catch(() => {})` | `refetch 抛错 / reject…`(node 会因未处理的 rejection 崩) |
+| `dispose` 里不清 timer | `dispose:…` 的「未到期的合并必须被清掉」 |
+| `orderStreamInstance` 的 URL 去掉 `encodeURIComponent` 也能过 —— **注意**:这条变异不会红(令牌是 URL-safe 的),它防的是未来别的字符,保留即可(如实记下:这条没有断言兜着) | — |
+
+- [ ] **Step 6: 提交**
+
+```bash
+git add frontend/src/utils/orderStreamInstance.ts frontend/src/composables/useLiveRefetch.ts \
+        frontend/src/composables/index.ts frontend/scripts/verify-live-refetch.mjs
+git commit -m "feat(订单实时): useLiveRefetch —— busy 挂起 / 静默补刷 / 500ms 合并"
+```
+
+### Task 10: 前端管线 —— 静默开关 + API 透传 + 登出停流 + 清死文件
+
+**Files:**
+- Modify: `frontend/src/utils/request.ts`(模块扩展 + 5 处 `ElMessage.error` 加闸 + 成功 toast 加闸)
+- Modify: `frontend/src/api/module_payment/collection.ts`(`getLsyOrderPage` / `getLsyRefundPage` 各加 `silent` 形参)
+- Modify: `frontend/src/api/module_payment/har.ts`(`orderList` / `orderDetail` 各加 `silent` 形参)
+- Modify: `frontend/src/store/modules/user.store.ts`(`resetAllState` 里停流)
+- Delete: `frontend/src/utils/websocket.ts`(**零引用死文件**)
+
+**Interfaces:**
+- Consumes: `stopOrderStream()`(Task 9)
+- Produces: axios 配置新增 `silent?: boolean`;`getLsyOrderPage(params, silent=false)` / `getLsyRefundPage(params, silent=false)` / `HarAPI.orderList(params, silent=false)` / `HarAPI.orderDetail(id, silent=false)`
+
+**为什么要有 silent**:后台自动刷新失败时弹 toast 是**用户没做过任何操作**的报错 —— 违反「零感知」。但 **401/TOKEN_EXPIRED 的处理一个字都不改**(登录失效必须跳登录页)。
+
+**这一步没有 node 脚本**:`request.ts` 的依赖链是 `axios + qs + @/utils/authRedirect → @/router + pinia store`,在 node 里打包它等于把整个应用拉起来。验证手段是「`ts:check` 同机差分 + grep 逐条点数 + Task 14 的浏览器级断言(弹窗打开时不出现 toast)」,**如实记下:拦截器的静默行为没有单测**。
+
+- [ ] **Step 1: 先量基线(这步不能省)**
+
+```bash
+cd frontend && pnpm ts:check 2>&1 | tee /tmp/ts-before.txt; echo "基线 exit=$?"
+```
+记下输出(**基线 exit 2 是正常的**,本仓有历史遗留类型错)。本轮所有前端任务都用「与这份输出 diff」判有没有新增错误。
+
+- [ ] **Step 2: 改 request.ts**
+
+import 之后(:10 `import { redirectToLogin } …` 那行下面)加模块扩展:
+
+```typescript
+declare module "axios" {
+  export interface AxiosRequestConfig {
+    /** 静默请求:失败不弹 toast(后台自动刷新用)。401 / TOKEN_EXPIRED 的跳登录行为不变 */
+    silent?: boolean;
+  }
+}
+```
+
+成功拦截器(:63-66)改成:
+
+```typescript
+    // 检查请求是否失败
+    if (data.code !== ResultEnum.SUCCESS) {
+      if (!response.config.silent) {
+        ElMessage.error(data.msg);
+      }
+      return Promise.reject(response);
+    }
+```
+
+成功提示(:69-75)加一个闸(GET 之外还有写操作会经 silent 路径时也不许弹):
+
+```typescript
+    if (
+      !response.config.silent &&
+      response.config.method?.toUpperCase() !== "GET" &&
+      !response.config.url?.includes("login") &&
+      !response.config.url?.includes("logout")
+    ) {
+      ElMessage.success(data.msg);
+    }
+```
+
+错误拦截器(:79)开头加一行,之后 4 处 `ElMessage.error` 全部加闸:
+
+```typescript
+  async (error: AxiosError<ApiResponse>) => {
+    const silent = error.config?.silent === true;
+```
+- :94 `if (!silent) { ElMessage.error(errorMessage); }`
+- :141-153 四个分支:`if (!silent) { ElMessage.error(data.msg || "…"); }`(每个分支只包 `ElMessage`,`return Promise.reject(...)` 照旧)
+- :133-140 的 401 / TOKEN_EXPIRED 分支**一个字都不许改**(那一支必须跳登录)
+- blob 分支(:101-119)不动(silent 路径全是 JSON)
+
+- [ ] **Step 3: 改两个 API 文件**
+
+`collection.ts`:
+
+```typescript
+export function getLsyOrderPage(
+  params: {
+    pageNo: number;
+    pageSize: number;
+    status?: string;
+    payScene?: string;
+    keyword?: string;
+    /**
+     * ★ 只有超管会传(2026-09-30)。后端 `LeshouyingOrderController#applyTenantFilter`
+     * 对非超管传它一律 403 —— 不是「忽略」,理由见那边的 javadoc。
+     */
+    tenantId?: number;
+  },
+  /** true = 后台静默刷新:失败不弹 toast(见 request.ts 的 silent 声明) */
+  silent = false
+) {
+  return request({ url: "/payment/collection/order/page", method: "get", params, silent });
+}
+```
+
+```typescript
+export function getLsyRefundPage(
+  params: {
+    pageNo: number;
+    pageSize: number;
+    status?: string;
+    keyword?: string;
+  },
+  silent = false
+) {
+  return request({ url: "/payment/collection/refund/page", method: "get", params, silent });
+}
+```
+
+`har.ts`:
+
+```typescript
+  /** 订单列表(分页);silent = 后台静默刷新 */
+  orderList(params: HarOrderQueryParams, silent = false) {
+    return request<ApiResponse<HarPageResult<HarOrderVO[]>>>({
+      url: `${API_PATH}/order`,
+      method: "get",
+      params,
+      silent,
+    });
+  },
+
+  /** 订单详情;silent = 后台静默刷新(详情弹窗实时联动用) */
+  orderDetail(id: string, silent = false) {
+    return request<ApiResponse<HarOrderVO>>({
+      url: `${API_PATH}/order/${id}`,
+      method: "get",
+      silent,
+    });
+  },
+```
+
+- [ ] **Step 4: 登出停流**
+
+`user.store.ts` 顶部加 import:
+
+```typescript
+import { stopOrderStream } from "@/utils/orderStreamInstance";
+```
+
+`resetAllState()`(:164-177)里 `Auth.clearAuth();` 之后插入:
+
+```typescript
+      // 停掉订单实时长连接:token 已经清了,继续挂着只会被 403 挡回、无限重连
+      stopOrderStream();
+```
+
+- [ ] **Step 5: 删死文件(删前重验一次零引用)**
+
+```bash
+cd frontend && grep -rn "utils/websocket" src --include=*.ts --include=*.vue | grep -v "^src/utils/websocket.ts" ; echo "↑ 空 = 可以删"
+git rm frontend/src/utils/websocket.ts   # 若本步在 frontend/ 里:git rm src/utils/websocket.ts
+```
+
+这条与实时推送无关,但它正是「订单长连接」这件事的**空壳遗骸**(一个零调用方的注册表);留着会让下一个人以为项目已有 WS 通道。删它同时清掉 `VITE_APP_WS_ENDPOINT` 相关的心智负担(env 变量本身不动,它属于 AI 聊天那条腿)。
+
+- [ ] **Step 6: 差分验证**
+
+```bash
+cd frontend && pnpm ts:check 2>&1 | tee /tmp/ts-after.txt; echo "改后 exit=$?"
+diff /tmp/ts-before.txt /tmp/ts-after.txt && echo "无新增类型错"
+```
+Expected: 输出 `无新增类型错`(或只有行号位移,逐条看过)。**出现新的 `文件(行,列)` 就是回归**,修掉再往下。
+
+再跑一遍 Task 8/9 的两个脚本确认没被波及:
+
+```bash
+cd frontend && node scripts/verify-order-stream.mjs && node scripts/verify-live-refetch.mjs
+```
+
+- [ ] **Step 7: 提交**
+
+```bash
+git add frontend/src/utils/request.ts frontend/src/api/module_payment/collection.ts \
+        frontend/src/api/module_payment/har.ts frontend/src/store/modules/user.store.ts
+git rm frontend/src/utils/websocket.ts
+git commit -m "feat(订单实时): 静默请求开关 + 列表 API 透传 + 登出停流;删零引用 websocket.ts"
+```
+
+### Task 11: 支付订单页(lsy)接线 —— 列表实时刷 + 抽屉联动
+
+**Files:**
+- Modify: `frontend/src/api/module_payment/collection.ts`(`getLsyOrderDetail` / `getLsyOrderRefunds` 各加 `silent` 形参 —— Task 10 只改了两条**列表**腿,这两条是抽屉联动需要的)
+- Modify: `frontend/src/views/module_payment/collection/order/index.vue`
+
+**Interfaces:**
+- Consumes: `useLiveRefetch(channels, refetch, options)`(Task 9)、`getLsyOrderPage(params, silent=false)`(Task 10)、`getLsyOrderDetail(outTradeNo, silent=false)` / `getLsyOrderRefunds(outTradeNo, silent=false)`(本任务 Step 1 补)
+- Produces: 无(末梢接线)
+
+**为什么要听两个通道**:本页的列里有 `refunded_amount` / 退款中标记 / `status`(`PARTIAL_REFUNDED`、`REFUNDED`)—— 那些字段只被**退款写库**改动,而退款写库发的是 `lsy_refund`。只听 `lsy_order` 的话,退完款页面停在旧金额上。
+
+**下面所有行号都是 2026-10-05 在本工作区量出来的**(`grep -n` 读数);实施时**按文本锚点找**,行号变了以文本为准。
+
+- [ ] **Step 1: 补两条详情腿的 silent 形参**
+
+`collection.ts` 里(`:42` 与 `:60`):
+
+```typescript
+export function getLsyOrderDetail(outTradeNo: string, silent = false) {
+  return request({ url: `/payment/collection/order/${outTradeNo}`, method: "get", silent });
+}
+```
+
+```typescript
+export function getLsyOrderRefunds(outTradeNo: string, silent = false) {
+  return request({
+    url: `/payment/collection/order/${outTradeNo}/refunds`,
+    method: "get",
+    silent,
+  });
+}
+```
+
+(`getLsyOrderRefunds` 上方那段「不要拿 getLsyRefundPage 代替」的注释**原样保留**。`getLsyNotifyLogs` 早已无人调用,**不动**。)
+
+- [ ] **Step 2: `loadData` 加静默参数**
+
+`:877-890` 的 `loadData`,改成(**原有的两条 `res.data.data` 注释块原样保留**):
+
+```typescript
+async function loadData(silent = false) {
+  // ★ 静默刷新不碰 loading:它是一个全局开关,后台那一次把它拉起来,
+  //   正在看页面的人会看到表格「闪一下转圈」—— 这正是「用户无感知」禁止的动静。
+  if (!silent) {
+    loading.value = true;
+  }
+  try {
+    const res: any = await getLsyOrderPage(query, silent);
+    // …(原有注释原样保留)…
+    list.value = res.data.data?.list || [];
+    total.value = res.data.data?.total || 0;
+  } finally {
+    if (!silent) {
+      loading.value = false;
+    }
+  }
+}
+```
+
+- [ ] **Step 3: 模板里的裸函数引用必须改成箭头函数(**这一步漏了就会静默变错**)**
+
+`:185`:
+
+```html
+        @change="loadData"
+```
+改成
+
+```html
+        @change="() => loadData()"
+```
+
+**理由(不写下来下一个人一定会改回去)**:`el-pagination` 的 `change` 事件带了两个参数 —— `(currentPage, pageSize)`,第一个是**数字**。裸传 `loadData` 时那个页码会落到 `silent` 上 ⇒ `silent = 2` 是 truthy ⇒ 翻页时**转圈消失、失败也不报错**,界面看起来「就是卡住了」,查半天查不出来。
+
+同一步做一次全量排查(**必须是空输出**):
+
+```bash
+cd frontend/src/views/module_payment && grep -n '="loadData"' collection/order/index.vue; echo "↑ 空 = 没有裸引用"
+```
+
+- [ ] **Step 4: 插入实时联动块**
+
+插入位置:**紧跟在 `loadData` 那个函数体结束之后、`function handleSearch() {` 之前**(`:890`/`:891` 之间)。此时 `selectedRows`(`:537`)、`batchRefund*`(`:608-620`)、`detailVisible`/`detail`/`refunds`(`:530-535`)都已声明,`loadData` 与下面两个函数都是函数声明(提升),不存在 TDZ。
+
+```typescript
+// ─────────────────── 实时联动(2026-10-05)───────────────────
+
+/**
+ * 退款确认框开着时也算「操作中」。
+ * 弹窗期间列表在脚下换行,操作员会重新核对金额 —— 而那个金额是从**行对象**里读的快照。
+ */
+const confirmingRefund = ref(false);
+
+/**
+ * 什么算「用户正在操作」,信号在此期间挂起:
+ * 勾选着行 / 批量退款的两个弹窗 / 提交与轮询在跑 / 退款确认框开着。
+ *
+ * ★ 详情抽屉**不在**这里 —— 抽屉要跟着实时变(见计划头「与已批 spec 的偏差」①):
+ *   收银台的人正盯着这一单等它从「支付中」翻成「支付成功」,挂起等于把这个功能关掉。
+ */
+const liveBusy = computed(
+  () =>
+    selectedRows.value.length > 0 ||
+    batchRefundVisible.value ||
+    batchRefundResultVisible.value ||
+    batchRefundRunning.value ||
+    confirmingRefund.value
+);
+
+/**
+ * 抽屉开着时的静默联动:两个请求都回来、且还是同一单,才整体覆盖。
+ *
+ * ★ 为什么不复用 `openDetail`:它**刻意先清空**(防上一次的内容在请求失败时冒充这一单),
+ *   那是「人点开某一单」的前提;后台刷新没有点错单的可能,闪一下空白反倒成了可见的动静。
+ * ★ 两个请求一起 await:只覆盖一半(比如详情新了、退款单还是旧的)会让界面自相矛盾。
+ */
+async function refreshOpenDetail() {
+  const no = detail.value?.out_trade_no;
+  if (!detailVisible.value || !no) {
+    return;
+  }
+  try {
+    const [d, rfs]: any[] = await Promise.all([
+      getLsyOrderDetail(no, true),
+      getLsyOrderRefunds(no, true),
+    ]);
+    // 请求在飞的时候人可能已经关了抽屉、或点开了另一单
+    if (detailVisible.value && detail.value?.out_trade_no === no) {
+      detail.value = d.data.data;
+      refunds.value = rfs.data.data || [];
+    }
+  } catch {
+    // 后台刷新失败静默:留着上一次的数据,比清空好
+  }
+}
+
+useLiveRefetch(
+  ["lsy_order", "lsy_refund"],
+  async () => {
+    await refreshOpenDetail();
+    await loadData(true);
+  },
+  { busy: liveBusy }
+);
+```
+
+导入(加到 `:430` 那一组 import 里):
+
+```typescript
+import { useLiveRefetch } from "@/composables";
+```
+
+- [ ] **Step 5: 退款确认框期间置位 `confirmingRefund`**
+
+`handleRefund` 里那段 `let input = ""; try { const { value } = await ElMessageBox.prompt(…) … } catch { return; }` 改成:
+
+```typescript
+  let input = "";
+  confirmingRefund.value = true;
+  try {
+    const { value } = await ElMessageBox.prompt(
+      /* …提示语与 options 一个字都不改… */
+    );
+    input = value ?? "";
+  } catch {
+    return; // 取消(ElMessageBox 以 "cancel" reject),不发请求
+  } finally {
+    // finally 在 catch 里那个 return 之前执行 ⇒ 取消路径也会复位,不会把页面永久卡在 busy
+    confirmingRefund.value = false;
+  }
+```
+
+不动的地方(**别顺手改**):`handleRefund` 结尾那句 `await openDetail({ out_trade_no: row.out_trade_no })` —— 那是**人主动点的退款**,点完闪一下是可以接受的;`finishBatchRefund` 里的 `loadData()`(收尾那次要转圈,操作员刚提交完在等结果);`onBeforeUnmount(() => stopBatchRefundPolling())`(与实时订阅无关,订阅的退订由 `useLiveRefetch` 自己挂)。
+
+- [ ] **Step 6: 差分验证**
+
+```bash
+cd frontend && pnpm ts:check 2>&1 | tee /tmp/ts-t11.txt; diff /tmp/ts-before.txt /tmp/ts-t11.txt && echo "无新增类型错"
+```
+
+形态核对(**逐条看,行号以实际为准**):
+
+```bash
+cd frontend/src/views/module_payment
+grep -n 'loadData' collection/order/index.vue
+```
+Expected: 定义 1 处(`async function loadData(silent = false) {`)+ `@change="() => loadData()"` 1 处 + `await loadData(true)` 1 处 + 其余全是 `loadData()` / `await loadData()`;**没有任何一处把 `loadData` 裸传**。
+
+```bash
+grep -n 'useLiveRefetch\|liveBusy\|refreshOpenDetail\|confirmingRefund' collection/order/index.vue
+```
+Expected: 每条都出现 ≥2 次(定义 + 使用)。
+
+- [ ] **Step 7: 提交**
+
+```bash
+git add frontend/src/api/module_payment/collection.ts \
+        frontend/src/views/module_payment/collection/order/index.vue
+git commit -m "feat(订单实时): 支付订单页接线 —— lsy_order+lsy_refund 双通道、操作中挂起、抽屉静默联动"
+```
+
+---
+
+### Task 12: 退款记录页(lsy)接线
+
+**Files:**
+- Modify: `frontend/src/views/module_payment/collection/refund/index.vue`
+
+**Interfaces:**
+- Consumes: `useLiveRefetch`(Task 9)、`getLsyRefundPage(params, silent=false)`(Task 10)
+- Produces: 无
+
+**页面现状(2026-10-05 实测,决定这一页怎么接)**:整个 `<script setup>` 只有 `query` / `list` / `total` / `loading` 四个状态(`:104-107`),**没有勾选、没有弹窗、没有行内操作按钮**(模板里只有查询/重置两个按钮 + 一个只读表格 + 分页)。⇒ **不传 `busy`**:这一页没有任何「操作中」的态,任何一帧都直接刷。
+
+- [ ] **Step 1: `loadData` 加静默参数**
+
+`:116-126`:
+
+```typescript
+async function loadData(silent = false) {
+  if (!silent) {
+    loading.value = true;
+  }
+  try {
+    const res: any = await getLsyRefundPage(query, silent);
+    // 两层 `.data.data`(响应拦截器不解包);分页字段名是 `list`(原有注释保留)
+    list.value = res.data.data?.list || [];
+    total.value = res.data.data?.total || 0;
+  } finally {
+    if (!silent) {
+      loading.value = false;
+    }
+  }
+}
+```
+
+- [ ] **Step 2: 模板 `@change="loadData"`(`:76`)→ `@change="() => loadData()"`**
+
+同 Task 11 Step 3 的理由(`el-pagination` 的 change 把页码当第一个实参)。
+
+- [ ] **Step 3: `onMounted(loadData)`(`:139`)→ `onMounted(() => loadData())`**
+
+Vue 调用生命周期钩子时**不传参**,所以 `onMounted(loadData)` 今天是对的;但下一个人看不出这一点,而这个函数现在有了可选首参 —— 写成箭头函数把它变成显式的。
+
+- [ ] **Step 4: 插入实时联动**
+
+插入位置:**紧接在 `onMounted(() => loadData());` 之前**(`:139` 处),此时 `query` / `loadData` 都已定义。
+
+```typescript
+// 退款记录页:只听 lsy_refund。本页没有勾选/弹窗/行内操作 ⇒ 不传 busy。
+// 退款的写入信号来自后端同一条广播(lsy_refund 通道),订单页与这一页各订各的。
+useLiveRefetch(["lsy_refund"], () => loadData(true));
+```
+
+导入(加到 `:88` 那一组里):
+
+```typescript
+import { useLiveRefetch } from "@/composables";
+```
+
+- [ ] **Step 5: 差分验证**
+
+```bash
+cd frontend && pnpm ts:check 2>&1 | tee /tmp/ts-t12.txt; diff /tmp/ts-before.txt /tmp/ts-t12.txt && echo "无新增类型错"
+cd src/views/module_payment && grep -n '="loadData"' collection/refund/index.vue; echo "↑ 空 = 没有裸引用"
+grep -n 'loadData\|useLiveRefetch' collection/refund/index.vue
+```
+Expected: `async function loadData(silent = false)`、`@change="() => loadData()"`、`onMounted(() => loadData())`、`loadData(true)`、`loadData()` 各 1 处,外加 `useLiveRefetch` 的 import 与调用。
+
+- [ ] **Step 6: 提交**
+
+```bash
+git add frontend/src/views/module_payment/collection/refund/index.vue
+git commit -m "feat(订单实时): 退款记录页接线 —— lsy_refund 单通道静默重拉"
+```
+
+---
+
+### Task 13: HAR 订单页接线
+
+**Files:**
+- Modify: `frontend/src/views/module_payment/har/order/index.vue`
+
+**Interfaces:**
+- Consumes: `useLiveRefetch`(Task 9)、`HarAPI.orderList(params, silent=false)` / `HarAPI.orderDetail(id, silent=false)`(Task 10)
+- Produces: 无
+
+**这一页是三条接线里最危险的一条**:分页事件的处理器是**裸函数名**(`:211` `@size-change="load"`、`:212` `@current-change="load"`),而 `el-pagination` 这两条事件都会把**新页码/新页长当作第一个实参**传进来。不加箭头函数的话 `silent` 会永远拿到一个数字。
+
+- [ ] **Step 1: `load` 加静默参数**
+
+`:566-586`:
+
+```typescript
+async function load(silent = false) {
+  if (!silent) {
+    loading.value = true;
+  }
+  try {
+    const res = await HarAPI.orderList(
+      {
+        enterprise_id: searchForm.enterprise_id || undefined,
+        merchant_code: searchForm.merchant_code || undefined,
+        order_no: searchForm.order_no || undefined,
+        out_biz_no: searchForm.out_biz_no || undefined,
+        trade_status: searchForm.trade_status || undefined,
+        trade_type: searchForm.trade_type || undefined,
+        start_date: searchForm.start_date || undefined,
+        end_date: searchForm.end_date || undefined,
+        page_no: pageNo.value,
+        page_size: pageSize.value,
+      },
+      silent
+    );
+    list.value = res.data.data?.items || res.data.data?.list || [];
+    total.value = res.data.data?.total || 0;
+  } finally {
+    if (!silent) {
+      loading.value = false;
+    }
+  }
+}
+```
+
+- [ ] **Step 2: 两处模板裸引用(`:211-212`)改成箭头函数**
+
+```html
+          @size-change="() => load()"
+          @current-change="() => load()"
+```
+
+`v-model:current-page` / `v-model:page-size` 在事件回调**之前**就写好了 ref,所以箭头函数里 `load()` 读到的是新页码 ✓。
+
+- [ ] **Step 3: 插入实时联动块**
+
+插入位置:**紧接在 `onMounted(async () => {` 之前**(`:772` 处)。此时 `createVisible` / `saving`(`:492-493`)、`detailVisible` / `detail`(`:496-497`)都已声明。
+
+```typescript
+// ─────────────────── 实时联动(2026-10-05)───────────────────
+
+/** 退款确认框(ElMessageBox.prompt)开着时也算操作中:弹窗背后列表不该在脚下换行。 */
+const confirmingRefund = ref(false);
+
+/**
+ * 「操作中」判据:创建弹窗开着 / 正在提交 / 退款确认框开着。
+ * ★ 不含 `syncingId` / `refundingId`:这两条腿自己结束时会 `load()`,期间行上按钮是转圈的
+ *   —— 后台静默刷新在这中间落地,只是把同样的数据重画一遍。
+ * ★ 不含 `detailVisible`:详情要跟着实时变(偏差 ①)。
+ */
+const liveBusy = computed(() => createVisible.value || saving.value || confirmingRefund.value);
+
+/** 详情弹层开着时的静默联动:只有还是同一单才覆盖(理由同 Task 11 Step 4)。 */
+async function refreshOpenDetail() {
+  const id = detail.value?.id;
+  if (!detailVisible.value || !id) {
+    return;
+  }
+  try {
+    const res = await HarAPI.orderDetail(id, true);
+    if (detailVisible.value && detail.value?.id === id) {
+      detail.value = res.data.data;
+    }
+  } catch {
+    // 静默
+  }
+}
+
+useLiveRefetch(
+  ["har_order"],
+  async () => {
+    await refreshOpenDetail();
+    await load(true);
+  },
+  { busy: liveBusy }
+);
+```
+
+导入:`:460` 的 `import { onMounted, reactive, ref } from "vue";` 加上 `computed`:
+
+```typescript
+import { onMounted, reactive, ref, computed } from "vue";
+```
+
+再加一行:
+
+```typescript
+import { useLiveRefetch } from "@/composables";
+```
+
+- [ ] **Step 4: 退款确认框期间置位 `confirmingRefund`**
+
+`handleRefund` 里 `let reason = ""; try { const { value } = await ElMessageBox.prompt(…) … } catch { return; }` 改成:
+
+```typescript
+  let reason = "";
+  confirmingRefund.value = true;
+  try {
+    const { value } = await ElMessageBox.prompt(
+      /* …提示语与 options 一个字都不改… */
+    );
+    reason = value ?? "";
+  } catch {
+    return; // 取消(ElMessageBox 以 "cancel" reject),不发请求
+  } finally {
+    confirmingRefund.value = false;
+  }
+```
+
+- [ ] **Step 5: 差分验证**
+
+```bash
+cd frontend && pnpm ts:check 2>&1 | tee /tmp/ts-t13.txt; diff /tmp/ts-before.txt /tmp/ts-t13.txt && echo "无新增类型错"
+cd src/views/module_payment && grep -n '="load"' har/order/index.vue; echo "↑ 空 = 没有裸引用"
+grep -n 'load(\|useLiveRefetch\|liveBusy\|refreshOpenDetail\|confirmingRefund\|import { onMounted' har/order/index.vue
+```
+Expected: `async function load(silent = false)`、两处 `() => load()`、`await load(true)`、其余 `load()`;`computed` 已在 vue 的 import 里。
+
+- [ ] **Step 6: 三页通道名审计(一次性,防拼错)**
+
+通道名是**前后端之间唯一的字符串契约**(后端 `OrderEventPublisher.CHANNEL_*` ↔ 前端 `useLiveRefetch` 的第一个实参)。拼错的表现是「某页永远不刷新」且没有任何报错。
+
+```bash
+cd /d/project2/payment-platform
+# 后端常量(三个通道名的唯一来源)
+grep -n 'CHANNEL_' java/src/main/java/com/payment/platform/module/payment/stream/OrderEventPublisher.java
+# 前端订阅点(应当恰好三处:order 页一个、refund 页一个、har 页一个)
+grep -rn 'useLiveRefetch(\[' frontend/src/views/
+```
+逐字比对:`"lsy_order"` / `"lsy_refund"` / `"har_order"` 三个字符串在两侧**拼写、下划线位置**完全一致。Task 8 的验证脚本里已有一条「三通道名契约」断言,但它钉的是前端自己的常量;**跨端这一半只能靠这次 grep + Task 14 的 E2E**。
+
+- [ ] **Step 7: 提交**
+
+```bash
+git add frontend/src/views/module_payment/har/order/index.vue
+git commit -m "feat(订单实时): HAR 订单页接线 —— har_order 单通道、分页裸引用改箭头函数、详情联动"
+```
+
+---
+
+### Task 14: 端到端验收 + 构建
+
+**Files:**
+- 不新增/修改源码(**这一任务只产出读数**;发现缺陷就回到对应任务修,别在这里打补丁)
+
+**Interfaces:**
+- Consumes: 前 13 个任务的全部产物
+- Produces: jar / dist 指纹与验收读数(修完就报,**不 push**)
+
+**前置**:库隧道 `localhost:5432` 在线;后端 8001、前端 dev(`pnpm dev`,5180)能起;浏览器可登 `q`(tenant 2)。
+
+- [ ] **Step 1: 后端全量测试**
+
+```bash
+cd /d/project2/payment-platform/java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test 2>&1 | tail -30
+```
+Expected: **本次 stdout** 的汇总行 `Tests run: <N>, Failures: 0, Errors: 0, Skipped: 0`。N = 既有 81 个测试类 + 本轮新增的 4 个(`OrderEventHubTest` / `OrderEventPublisherTest` / `OrderEventRelayTest` / `OrderStreamControllerTest`)。**别读 `target/surefire-reports/`** —— 它跨运行累积,会把上次的红/绿混进来。
+
+- [ ] **Step 2: 打 jar + 指纹**
+
+```bash
+cd /d/project2/payment-platform/java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -DskipTests package
+ls -l --time-style=full-iso target/*.jar
+```
+记下 **文件名 + 字节数 + mtime**(判新旧只看 mtime,不看版本号)。
+
+- [ ] **Step 3: 前端构建 + 产物证明**
+
+```bash
+cd /d/project2/payment-platform/frontend && pnpm build; echo "exit=$?"
+ls -l --time-style=full-iso dist/index.html
+ls -l dist/assets | head -5
+# 新代码确实进了产物:这三条必须都有命中
+grep -rl "payment/stream/orders" dist/assets | wc -l          # ≥1
+grep -ro 'lsy_order\|lsy_refund\|har_order' dist/assets | sort | uniq -c   # 三个通道名各自命中
+```
+记下 `dist/` 指纹。Expected: `pnpm build` exit 0;三条 grep 都有输出。
+
+- [ ] **Step 4: 起服务,先做 curl 级的 SSE 断言(不依赖浏览器,最能定案的一组)**
+
+```bash
+# ① 无 token ⇒ 403(偏差 ③:SecurityConfig 没配 entryPoint,匿名走的是业务侧 403)
+curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8001/api/v1/payment/stream/orders
+# ② 坏 token ⇒ 401(JwtAuthFilter 认得出这是个 token,只是不合法)
+curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer not-a-jwt" \
+  http://localhost:8001/api/v1/payment/stream/orders
+# ③ 好 token ⇒ 响应头 + 20 秒内必到的一帧 ping
+TOKEN='<浏览器 localStorage 里的 access_token>'
+curl -sN -D /tmp/sse-head.txt -H "Authorization: Bearer $TOKEN" --max-time 25 \
+  http://localhost:8001/api/v1/payment/stream/orders | head -20
+grep -i "content-type\|x-accel-buffering\|cache-control" /tmp/sse-head.txt
+```
+Expected: ①`403` ②`401`;③ 三个头分别是 `text/event-stream`(或带 `;charset=UTF-8`)、`no`、`no-cache`,且 body 里出现 `event: ping`(间隔 20 秒,`--max-time 25` 保证抓得到一帧)。
+
+- [ ] **Step 5: 一帧 write→push 的闭合验证(同一个 token,两个终端)**
+
+终端 A(挂着收帧,60 秒):
+
+```bash
+curl -sN -H "Authorization: Bearer $TOKEN" --max-time 60 \
+  http://localhost:8001/api/v1/payment/stream/orders > /tmp/sse-body.txt
+```
+
+终端 B(触发一次真实写入 —— 收银台下单,**1 元**,最小金额;`amount` 单位是**分**):
+
+```bash
+curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
+  -d '{"amount":100,"remark":"sse-e2e"}' \
+  http://localhost:8001/api/v1/payment/collection/cashier/qrcode
+sleep 2
+grep -A1 '^data:' /tmp/sse-body.txt | tail -6
+```
+Expected: 2 秒内 `/tmp/sse-body.txt` 里出现 `data: {"channel":"lsy_order"}`。
+
+(若这支接口回 403 —— 它的权限串不一定在 q 名下 —— **先读响应体再决定**:可以让 q 用浏览器点页面上已有的下单按钮来触发同一次写入,或改走开放 API 那条腿。别猜权限串,也别为了让测试跑通去改权限。)
+
+**这条用例同时是「信号不早于提交」的实证**:收银台下单腿**有事务**,而页面靠重拉列表看到新行 —— 信号若在提交前发出,重拉会看不到这一行(Task 14 Step 6 的浏览器断言把它变成必红用例)。
+
+- [ ] **Step 6: 浏览器级断言(Playwright MCP,登 q)**
+
+逐条记录**实际看到的东西**(截图/原始响应),不要只记「通过」:
+
+1. **一条连接**:打开支付订单页 → Network 里 `stream/orders` 恰好 1 条且长期 `pending`;切到退款记录页、HAR 订单页 → **仍是那 1 条**(模块级单例;页面切换不新增连接)。
+2. **新增订单自动出现**:重跑 Step 5 的收银台下单 → 支付订单页 1 秒内多出一行,**没有点任何东西**。
+3. **租户隔离 + 正控(spec 验收 #2)**:登 q(tenant 2)打开支付订单页,记下 `total` 与 Network 里 `/order/page` 的请求数 → 让 **tenant 27 的一笔单变化** → q 的页面**纹丝不动**(`total` 不变、不出现新的 `/order/page` 请求)。**正控必须先量**:用 q 自己的令牌下一单 → q 的页面**必须变** —— 「不变」只有在「会变」被量到之后才算证据。
+   - 本机**没有** tenant 27 的凭据(已知其登录手机号 15616004668,密码未持有)⇒ 这一步要么**向用户要一次 27 的账号**,要么**如实记「E2E 隔离未跑,隔离只有 Task 1 的单元级变异覆盖」**。**不许**因为「单元用例绿了」就在验收单上写隔离通过 —— 那是两种量具。
+4. **零感知**:整个过程 `.el-message`(toast)数量为 0;页面没有任何新元素/角标/状态点(与改前截图对比)。
+5. **操作中挂起 + 回安全态补刷**:勾选一行 → 再下一单 → 列表**不动**(`total` 与首行不变)→ 取消勾选 → 无需操作,1 秒内列表自己补上。
+6. **抽屉联动(偏差 ① 的验收)**:打开某单详情 → 对同一单触发退款(或等回调)→ 抽屉里的状态/已退金额跟着变,**且不闪空白**。
+7. **连掉再连回**:停后端 → 观察页面(不报错、不跳登录、无 toast)→ 起后端 → **不做任何操作**,列表在重连补拉后自动补齐停服期间的变更。
+8. **静置 2 分钟**:连接仍 `pending`,Network 里 `stream/orders` 仍只有 1 条(本机经 Vite 代理;**生产 nginx 的缓冲只能部署后验**,见 Review Focus ⑤)。
+
+- [ ] **Step 7: 交付读数汇总(写给人看,不 push)**
+
+把 Step 1-3 的三组指纹(测试汇总行 / jar 名+字节+mtime / dist 指纹)与 Step 4-6 的实测结果一起贴出来。**提交已经按任务做完了**;是否 push、是否部署(重打 jar + dist 上线)由用户拍板 —— 参考本仓的一条既有教训:**没提交的交付等于没交付**,而这些提交目前仍在本地 `java` 分支上。
+
+```bash
+cd /d/project2/payment-platform && git log --oneline -14
+```
+
+---
+
+## 完成判据(全部满足才算实现完成)
+
+- [ ] `mvn test` 全绿(本次 stdout 汇总行)
+- [ ] `pnpm build` exit 0,且三个通道名出现在 `dist/assets`
+- [ ] curl 三连:无 token 403 / 坏 token 401 / 好 token 收得到 `event: ping` 与 `data: {"channel":"lsy_order"}`
+- [ ] 三页在真实写入后**无需操作**自动更新;`.el-message` 数为 0
+- [ ] 勾选期间不刷、取消勾选后补刷一次
+- [ ] 断线自动重连 + 重连后补拉,全程无用户可见状态
+- [ ] 全部改动**已提交**(`git log --oneline` 可见每个任务各自的提交)