|
|
@@ -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` 可见每个任务各自的提交)
|