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 |
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。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 里)。new 这些类,只有尾追加才不用重排实参。org.junit.jupiter.api.Assertions,全仓 79 个测试类用它);本仓没有 AssertJ(0 个类),不要写 assertThat。jakarta.* 注解,如 jakarta.annotation.PostConstruct)、Java 21。@SpringBootTest(现状读数),拉一个起来会撞 leshouying.enabled 启动闸 + 库隧道依赖。拆成两半验:「提交后才投递」= relay 的委托单测 + 注解断言(Task 2 把 AFTER_COMMIT/fallbackExecution 钉在测试里)+ 真有事务的开放 API 建单腿 E2E(Task 14:建单后 1 秒内页面出现新行;若信号早于提交,重拉看不到新行 ⇒ 用例必红)。SecurityConfig 没配 entryPoint ⇒ 匿名被拒时走 Spring Security 的默认 Http403ForbiddenEntryPoint(403);而 401 是 JwtAuthFilter 自己抛的(坏 token 才走那条)。Task 3 的测试按 403 写(它测的是 controller 在 SecurityContext 里取不到用户时的防御分支)。真实链路上的 403/401 由 Task 14 Step 4 当场 curl 量;量出来若不是这两个数,改的是那条断言与这句说明,不是改代码去迎合文档。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),同样以计划为准。按 spec 措辞实现完之后,最可能让人踩坑的五件事(每条都配了测试,括号是责任任务):
SseEmitter 不允许多线程并发写,症状是偶发 IllegalStateException、帧交叠、连接莫名断。→ Task 1 生产路径的每一帧都在order_sse线程上发出(8 线程并发提交 + 线程名断言)。stopOrderStream 用例 + Task 10 的 resetAllState 接线。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后端修改(只加字段 + 发行,不动既有逻辑):
leshouying/service/LsyOrderService.javaleshouying/controller/LeshouyingCashierController.java、LeshouyingCheckoutController.java、controller/open/LeshouyingOpenController.java、scheduler/LsyOrderPollScheduler.java、scheduler/LsyReconcileScheduler.javaleshouying/service/LsyRefundService.javahar/service/HarOrderService.java、HarNotifyService.java、HarCallbackService.javaLsyOrderService 的 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,删前重验)Files:
java/src/main/java/com/payment/platform/module/payment/stream/OrderEventHub.javajava/src/test/java/com/payment/platform/module/payment/stream/OrderEventHubTest.javaInterfaces:
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 = 3000connectionCount() / tenantConnectionCount(long) / tenantBucketCount()(仅测试用,生产代码不许调)[ ] Step 1: 写失败的测试
创建 java/src/test/java/com/payment/platform/module/payment/stream/OrderEventHubTest.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 才来一次,用例早结束了)
}
}
}
Run: cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest=OrderEventHubTest
Expected: 编译失败 cannot find symbol: class OrderEventHub(Java 里新类没建就是「函数不存在」的等价红)。
创建 java/src/main/java/com/payment/platform/module/payment/stream/OrderEventHub.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();
}
}
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
逐个做,每个做完还原再跑一次确认仍绿:
| 变异(只改一处) | 期望变红的用例 |
|---|---|
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: 提交
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 桶 + 心跳 + 单线程串行"
Files:
java/src/main/java/com/payment/platform/module/payment/stream/OrderChangedEvent.javajava/src/main/java/com/payment/platform/module/payment/stream/OrderEventPublisher.javajava/src/main/java/com/payment/platform/module/payment/stream/OrderEventRelay.javajava/src/test/java/com/payment/platform/module/payment/stream/OrderEventPublisherTest.javajava/src/test/java/com/payment/platform/module/payment/stream/OrderEventRelayTest.javaInterfaces:
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:
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:
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 = 乐收赢那些刻意无事务的写库腿一帧都发不出去(静默失效)");
}
}
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
创建 OrderChangedEvent.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:
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:
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());
}
}
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
| 变异 | 期望变红 |
|---|---|
relay 的注解 fallbackExecution = true 改成默认(删掉参数) |
theAfterCommitSemanticsArePinnedInCode |
relay 的 phase 改成 BEFORE_COMMIT |
同上 |
OrderEventPublisher.CHANNEL_LSY_REFUND 改成 "lsy_refunds" |
channelNames_areTheWireContractWithFrontend |
[ ] Step 6: 提交
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(订单实时): 领域事件三件套 —— 提交后转发(无事务兜底)"
Files:
java/src/main/java/com/payment/platform/module/payment/stream/OrderStreamController.javajava/src/test/java/com/payment/platform/module/payment/stream/OrderStreamControllerTest.javaInterfaces:
OrderEventHub.register(Long, boolean)(Task 1)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,别新造一个)。
创建 OrderStreamControllerTest.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(), "匿名不许往桶里挂空连接");
}
}
Run: cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest=OrderStreamControllerTest
Expected: 编译失败 cannot find symbol: class OrderStreamController
创建 OrderStreamController.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()));
}
}
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
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 目录——它跨运行累积)
| 变异 | 期望变红 |
|---|---|
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: 提交
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= 鉴权)"
Files:
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOrderService.java(字段块末尾 + 两处 return true; 之前)LsyOrderServiceStateTest.java:43、LsyBarcodePayTest.java:37、LsyH5PayTest.java:37、LsyOrderUpstreamRejectTest.java:53、LsyQrPayTest.java:43LsyOrderServiceStateTest.java 里加 4 个新用例Interfaces:
OrderEventPublisher.changed(String, Long)、CHANNEL_LSY_ORDER(Task 2)LsyOrderService 构造器 5 参 → 6 参(…, LsyQueryService queryService, OrderEventPublisher publisher))为什么收口在这两处:applyPayResult 是全仓唯一的「订单状态回写」收口(建单尾、支付回调、轮询确认、关单拒绝、对账都用它),applyRefundResult 是退款结算改订单行(refunded_amount / 状态)的唯一收口 —— 收在这里,9 个调用点一次全覆盖,且天然满足「守卫没命中就什么都不发」。
改 LsyOrderServiceStateTest.java:@Mock 块末尾加一行,setUp 改成 6 参:
@Mock private LsyOrderMapper orderMapper;
@Mock private LeshouyingClient client;
@Mock private LsyQueryService queryService;
@Mock private OrderEventPublisher publisher; // ← 新增
props = new LeshouyingProperties();
// 构造器签名固定为 6 参(2026-10-05 加 OrderEventPublisher)—— 后续新增下单方法时不要改它
service = new LsyOrderService(orderMapper, client,
new LeshouyingResponseParser(), props, queryService, publisher);
在类尾追加 4 个新用例(夹具照抄同类既有用例:order(...)/patch(...) 是类内现成的私有工厂):
@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;
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 里这就是「函数签名不存在」的等价红。
LsyOrderService.java —— import 加 import com.payment.platform.module.payment.stream.OrderEventPublisher;,字段块末尾追加(现有字段块 :40-44,末行是 private final LsyQueryService queryService;):
/** 订单信号发布(SSE):只在**真的改了行**之后发 —— 守卫没命中的分支一行都没变,
* 发了就是让三页白拉一次。 */
private final OrderEventPublisher publisher;
发布点 A —— applyPayResult 成功尾(if (patch.getUpstreamResultCode() != null) { … } 之后、return true; 之前,:152-156 附近):
if (patch.getUpstreamResultCode() != null) {
order.setUpstreamResultCode(patch.getUpstreamResultCode());
}
publisher.changed(OrderEventPublisher.CHANNEL_LSY_ORDER, order.getTenantId());
return true;
发布点 B —— applyRefundResult 的 if (rows == 1) 块内(结算成功的 return 之前):
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(都是多行调用,改的是收尾 ); 前那一行)。改完自检:
cd java && grep -rn -A2 "new LsyOrderService(" src/test/java | grep -c "publisher"
Expected: 5(5 个构造点全部带上)
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 参构造、不受影响)
| 变异 | 期望变红 |
|---|---|
| 删掉发布点 A 那一行 | applyPayResult_affectedRowIsOne_emitsLsyOrderSignal |
把发布点 A 挪到 int rows = 之前(无论命中与否都发) |
applyPayResult_affectedRowIsZero_emitsNothing |
发布点 B 的通道名写成 CHANNEL_HAR_ORDER |
applyRefundResult_settledRow_emitsLsyOrderSignal |
order.getTenantId() 换成 null |
上面两条 emit 用例(租户丢了) |
[ ] Step 6: 提交
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 个测试类)"
Files:
leshouying/controller/LeshouyingCashierController.java(字段 + insert + 收银台撤销)leshouying/controller/LeshouyingCheckoutController.java(字段 + insert)leshouying/controller/open/LeshouyingOpenController.java(字段 + 3 处 insert + 关单)leshouying/scheduler/LsyOrderPollScheduler.java(字段 + 自动撤销)leshouying/scheduler/LsyReconcileScheduler.java(字段 + 关单 + 过期)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:
OrderEventPublisher.changed(String, Long)、CHANNEL_LSY_ORDERlsy_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 附近) |
插入的这一行(每个点都一样,只有空格缩进跟着上下文):
publisher.changed(OrderEventPublisher.CHANNEL_LSY_ORDER, order.getTenantId());
不发布的三处(有意为之,审计时不是遗漏):LsyOrderService.stageCodeUrl / markNextQuery(游标类 updateById,订单列表页看不到)、LsyCallbackService.recordOrderNotifyResult(notify_status 列 2026-10-05 已从订单页下线,order/index.vue:145/:272 有「别顺手补回来」的注释)。
在每个既有测试类里加用例,夹具照抄括号里点名的既有用例,只加发布断言:
LeshouyingOpenControllerTest(夹具抄 barcodePay_mapsDtoToEntityAndReturnsResult :87):
@Test
void barcodePay_publishesLsyOrderSignal() {
// 夹具与 barcodePay_mapsDtoToEntityAndReturnsResult 逐行一致(同 mock 同入参)
…(照抄该用例的 when/stub 与 controller 调用)…
verify(publisher).changed(OrderEventPublisher.CHANNEL_LSY_ORDER, TENANT_ID);
}
LeshouyingOpenControllerTest(夹具抄 orderClose_条件更新命中时回写目标状态 :536 / orderClose_条件更新未命中时不得谎报状态 :517):
@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。
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 变红。
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 的注释):
/** 订单信号发布(SSE):只在**真的改了行**之后发。 */
private final OrderEventPublisher publisher;
按上表插 8 行发布。插完自检(每一处都该是 1):
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: 审计(防止漏网写点)
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 已加的发布点紧邻(同一方法、发布行就在写语句之后),要么属于「不发布的三处」清单。出现第四类就停下来问,别自行判断。
Run: cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test
Expected: 本次 stdout 汇总行 Failures: 0, Errors: 0(读汇总行)
| 变异 | 期望变红 |
|---|---|
删掉 LeshouyingOpenController 三处 insert 发布中的任意一处 |
对应的 *_publishesLsyOrderSignal 用例 |
orderClose 的发布行挪到条件更新之前 |
orderClose_条件更新未命中_不发信号 |
markExpired 的发布行删掉 |
markExpired 那条新断言 |
[ ] Step 7: 提交
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 建单/关单/自动撤销/过期八处写点发布信号"
Files:
leshouying/service/LsyRefundService.java(字段块末尾 + 4 处写点)LsyRefundServiceTest.java:40(7 参 → 8 参)LsyRefundServiceTest.java 里加 5 条断言Interfaces:
OrderEventPublisher.changed(String, Long)、CHANNEL_LSY_REFUNDLsyRefundService 构造器 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); 之前 |
LsyRefundServiceTest.java:@Mock 块加 @Mock private OrderEventPublisher publisher;,:40 的构造点尾追加 , publisher,然后加断言(夹具照抄括号里的既有用例,只加发布断言):
// ① 夹具抄 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 用该类既有的租户常量/夹具值;占位处必须换成类里真实存在的那个名字,别新造。)
Run: cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest=LsyRefundServiceTest
Expected: 编译失败(8 参构造器不存在)→ 补构造点后新断言 Wanted but not invoked 红。
import com.payment.platform.module.payment.stream.OrderEventPublisher;字段块末尾(private final LeshouyingProperties props; :76 之后):
/** 退款信号发布(SSE):只在**真的写了行**之后发(`settle == null` 那种早退分支不发)。 */
private final OrderEventPublisher publisher;
四个发布点插入的都是这一行(变量名按上下文;② 用 entity.getTenantId(),③④ 用 refund.getTenantId()):
publisher.changed(OrderEventPublisher.CHANNEL_LSY_REFUND, entity.getTenantId());
自检:
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 表不许有漏网写点)
cd java/src/main/java/com/payment/platform/module/payment/leshouying
grep -rn "refundMapper\.\(insert\|update\|updateById\)(" .
每一处要么紧邻一条发布行,要么属于下面两类有意不发:LsyCallbackService.recordRefundNotifyResult(notify_status 列已从退款页下线,页面不展示)、LsyReconcileScheduler.pushNextReconcileTime 一类纯游标写。出现第三类就停下来问。
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: 提交
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 退款表四处写点发布信号"
Files:
har/service/HarOrderService.java(字段块末尾 + 6 处写点)har/service/HarNotifyService.java(字段块末尾 + 1 处写点)har/service/HarCallbackService.java(显式构造器 +1 参 + 1 处写点)HarOrderServiceTest.java:54、HarNotifyServiceTest.java:60Interfaces:
OrderEventPublisher.changed(String, Long)、CHANNEL_HAR_ORDERHarOrderService 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 列)。这是本计划里唯一一处「改了但没单测钉住」的实现 —— 评审时按此定位。
HarOrderServiceTest.java:@Mock 块末尾加 @Mock private OrderEventPublisher publisher;,:54 构造点尾追加 , publisher。在既有用例上加断言:
// 抄 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 用每个类里既有的租户常量/夹具值,别新造。)
Run: cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest='HarOrderServiceTest+HarNotifyServiceTest'
Expected: 编译失败(构造器不匹配)→ 补构造点后新断言红。
三个类各自 import + 字段块末尾加:
/** 订单信号发布(SSE):只在**真的写了行**之后发。 */
private final OrderEventPublisher publisher;
HarCallbackService 是显式构造器(:73-88):字段按现有顺序加在 restTemplate 之后、callbackPool 之前,构造器实参表尾追加 OrderEventPublisher publisher 并 this.publisher = publisher;。写点:
latest.setNotifyStatus(ok ? HarNotifyStatus.SUCCESS : HarNotifyStatus.FAILED);
orderMapper.updateById(latest);
publisher.changed(OrderEventPublisher.CHANNEL_HAR_ORDER, latest.getTenantId());
其余 7 处插的都是这一行(变量名按上下文:order / entity):
publisher.changed(OrderEventPublisher.CHANNEL_HAR_ORDER, order.getTenantId());
自检:
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 表)
cd java/src/main/java/com/payment/platform/module/payment/har
grep -rn "orderMapper\.\(insert\|update\|updateById\)(" .
每处要么紧邻发布行,要么是上面点名的「有意不发」那一处(syncFromUpstream 失败分支的游标写)。出现第三类就停下来问。
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 代码
| 变异 | 期望变红 |
|---|---|
删掉 doRefund 的发布行 |
openRefund_preemptsToRefunding… 的断言 |
把 syncFromUpstream 失败分支也加上发布 |
sync_queryFailure_stillAdvancesNextQueryTime 的 never() |
删掉 rollbackRefundPreempt 的发布行 |
openRefund_upstreamNonSuccess_rollsBackToPaid 的断言 |
[ ] Step 7: 提交
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 订单表八处写点发布信号(含回滚与回调状态)"
Files:
frontend/scripts/verify-order-stream.mjsfrontend/src/utils/orderStream.ts(纯模块:零 import,依赖全部注入 —— 这样 node 脚本能原样打包跑)frontend/scripts/ 目录(现在不存在,Write 会自动建)Interfaces:
createOrderStream(deps: OrderStreamDeps): OrderStreamOrderStream.subscribe(channel, handler): () => void / .onResync(handler): () => void / .stop(): voidOrderStreamDeps = { 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 换成注入的空壳。
创建 frontend/scripts/verify-order-stream.mjs:
#!/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);
Run: cd frontend && node scripts/verify-order-stream.mjs
Expected: esbuild 报错 Could not resolve "…/src/utils/orderStream.ts"(模块还不存在),退出码非 0。
创建 frontend/src/utils/orderStream.ts:
/**
* 订单实时信号(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;
}
Run: cd frontend && node scripts/verify-order-stream.mjs
Expected: 10 条 PASS + 全部通过,退出码 0
| 变异 | 期望变红 |
|---|---|
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: 提交
git add frontend/scripts/verify-order-stream.mjs frontend/src/utils/orderStream.ts
git commit -m "feat(订单实时): 前端 SSE 单例工厂(退避重连 + 看门狗 + 重连补拉)"
Files:
frontend/src/utils/orderStreamInstance.ts(应用单例:真 EventSource + 现取令牌 URL)frontend/src/composables/useLiveRefetch.tsfrontend/scripts/verify-live-refetch.mjsfrontend/src/composables/index.ts(加导出)Interfaces:
createOrderStream / OrderStream(Task 8)、Auth.getAccessToken(既有)useLiveRefetch(channels: string[], refetch: () => unknown, options?: { busy?: Ref<boolean>; debounceMs?: number; stream?: OrderStream }): () => voidorderStream(): OrderStream(懒建单例)、stopOrderStream(): void(登出停流)语义(四条,逐条有断言):
refetch()(同一次写入的多帧只拉一次)busy === true 期间任何信号(含重连补拉)只记 pending、不刷busy 由 true 翻 false 且期间有 pending → 立刻静默补刷一次(不等下一个信号)busy 翻转但没有 pending 时什么都不做(防「每次关弹窗都白拉一次」)refetch 抛错/reject → 吞掉(后台刷新失败不许弹窗、不许打断用户)创建 frontend/scripts/verify-live-refetch.mjs:
#!/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);
Run: cd frontend && node scripts/verify-live-refetch.mjs
Expected: esbuild 报错 Could not resolve "…/src/composables/useLiveRefetch.ts"(模块还不存在),退出码非 0。
创建 frontend/src/utils/orderStreamInstance.ts:
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:
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 追加:
// 订单实时刷新
export { useLiveRefetch } from "./useLiveRefetch";
export type { LiveRefetchOptions } from "./useLiveRefetch";
Run: cd frontend && node scripts/verify-live-refetch.mjs
Expected: 9 条 PASS + 全部通过,退出码 0
| 变异 | 期望变红 |
|---|---|
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: 提交
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 合并"
Files:
frontend/src/utils/request.ts(模块扩展 + 5 处 ElMessage.error 加闸 + 成功 toast 加闸)frontend/src/api/module_payment/collection.ts(getLsyOrderPage / getLsyRefundPage 各加 silent 形参)frontend/src/api/module_payment/har.ts(orderList / orderDetail 各加 silent 形参)frontend/src/store/modules/user.store.ts(resetAllState 里停流)frontend/src/utils/websocket.ts(零引用死文件)Interfaces:
stopOrderStream()(Task 9)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: 先量基线(这步不能省)
cd frontend && pnpm ts:check 2>&1 | tee /tmp/ts-before.txt; echo "基线 exit=$?"
记下输出(基线 exit 2 是正常的,本仓有历史遗留类型错)。本轮所有前端任务都用「与这份输出 diff」判有没有新增错误。
import 之后(:10 import { redirectToLogin } … 那行下面)加模块扩展:
declare module "axios" {
export interface AxiosRequestConfig {
/** 静默请求:失败不弹 toast(后台自动刷新用)。401 / TOKEN_EXPIRED 的跳登录行为不变 */
silent?: boolean;
}
}
成功拦截器(:63-66)改成:
// 检查请求是否失败
if (data.code !== ResultEnum.SUCCESS) {
if (!response.config.silent) {
ElMessage.error(data.msg);
}
return Promise.reject(response);
}
成功提示(:69-75)加一个闸(GET 之外还有写操作会经 silent 路径时也不许弹):
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 全部加闸:
async (error: AxiosError<ApiResponse>) => {
const silent = error.config?.silent === true;
if (!silent) { ElMessage.error(errorMessage); }if (!silent) { ElMessage.error(data.msg || "…"); }(每个分支只包 ElMessage,return Promise.reject(...) 照旧)blob 分支(:101-119)不动(silent 路径全是 JSON)
[ ] Step 3: 改两个 API 文件
collection.ts:
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 });
}
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:
/** 订单列表(分页);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,
});
},
user.store.ts 顶部加 import:
import { stopOrderStream } from "@/utils/orderStreamInstance";
resetAllState()(:164-177)里 Auth.clearAuth(); 之后插入:
// 停掉订单实时长连接:token 已经清了,继续挂着只会被 403 挡回、无限重连
stopOrderStream();
[ ] Step 5: 删死文件(删前重验一次零引用)
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: 差分验证
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 的两个脚本确认没被波及:
cd frontend && node scripts/verify-order-stream.mjs && node scripts/verify-live-refetch.mjs
[ ] Step 7: 提交
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"
Files:
frontend/src/api/module_payment/collection.ts(getLsyOrderDetail / getLsyOrderRefunds 各加 silent 形参 —— Task 10 只改了两条列表腿,这两条是抽屉联动需要的)frontend/src/views/module_payment/collection/order/index.vueInterfaces:
useLiveRefetch(channels, refetch, options)(Task 9)、getLsyOrderPage(params, silent=false)(Task 10)、getLsyOrderDetail(outTradeNo, silent=false) / getLsyOrderRefunds(outTradeNo, silent=false)(本任务 Step 1 补)为什么要听两个通道:本页的列里有 refunded_amount / 退款中标记 / status(PARTIAL_REFUNDED、REFUNDED)—— 那些字段只被退款写库改动,而退款写库发的是 lsy_refund。只听 lsy_order 的话,退完款页面停在旧金额上。
下面所有行号都是 2026-10-05 在本工作区量出来的(grep -n 读数);实施时按文本锚点找,行号变了以文本为准。
collection.ts 里(:42 与 :60):
export function getLsyOrderDetail(outTradeNo: string, silent = false) {
return request({ url: `/payment/collection/order/${outTradeNo}`, method: "get", silent });
}
export function getLsyOrderRefunds(outTradeNo: string, silent = false) {
return request({
url: `/payment/collection/order/${outTradeNo}/refunds`,
method: "get",
silent,
});
}
(getLsyOrderRefunds 上方那段「不要拿 getLsyRefundPage 代替」的注释原样保留。getLsyNotifyLogs 早已无人调用,不动。)
loadData 加静默参数:877-890 的 loadData,改成(原有的两条 res.data.data 注释块原样保留):
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;
}
}
}
:185:
@change="loadData"
改成
@change="() => loadData()"
理由(不写下来下一个人一定会改回去):el-pagination 的 change 事件带了两个参数 —— (currentPage, pageSize),第一个是数字。裸传 loadData 时那个页码会落到 silent 上 ⇒ silent = 2 是 truthy ⇒ 翻页时转圈消失、失败也不报错,界面看起来「就是卡住了」,查半天查不出来。
同一步做一次全量排查(必须是空输出):
cd frontend/src/views/module_payment && grep -n '="loadData"' collection/order/index.vue; echo "↑ 空 = 没有裸引用"
插入位置:紧跟在 loadData 那个函数体结束之后、function handleSearch() { 之前(:890/:891 之间)。此时 selectedRows(:537)、batchRefund*(:608-620)、detailVisible/detail/refunds(:530-535)都已声明,loadData 与下面两个函数都是函数声明(提升),不存在 TDZ。
// ─────────────────── 实时联动(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 里):
import { useLiveRefetch } from "@/composables";
confirmingRefundhandleRefund 里那段 let input = ""; try { const { value } = await ElMessageBox.prompt(…) … } catch { return; } 改成:
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: 差分验证
cd frontend && pnpm ts:check 2>&1 | tee /tmp/ts-t11.txt; diff /tmp/ts-before.txt /tmp/ts-t11.txt && echo "无新增类型错"
形态核对(逐条看,行号以实际为准):
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 裸传。
grep -n 'useLiveRefetch\|liveBusy\|refreshOpenDetail\|confirmingRefund' collection/order/index.vue
Expected: 每条都出现 ≥2 次(定义 + 使用)。
[ ] Step 7: 提交
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 双通道、操作中挂起、抽屉静默联动"
Files:
frontend/src/views/module_payment/collection/refund/index.vueInterfaces:
useLiveRefetch(Task 9)、getLsyRefundPage(params, silent=false)(Task 10)页面现状(2026-10-05 实测,决定这一页怎么接):整个 <script setup> 只有 query / list / total / loading 四个状态(:104-107),没有勾选、没有弹窗、没有行内操作按钮(模板里只有查询/重置两个按钮 + 一个只读表格 + 分页)。⇒ 不传 busy:这一页没有任何「操作中」的态,任何一帧都直接刷。
loadData 加静默参数:116-126:
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;
}
}
}
@change="loadData"(:76)→ @change="() => loadData()"同 Task 11 Step 3 的理由(el-pagination 的 change 把页码当第一个实参)。
onMounted(loadData)(:139)→ onMounted(() => loadData())Vue 调用生命周期钩子时不传参,所以 onMounted(loadData) 今天是对的;但下一个人看不出这一点,而这个函数现在有了可选首参 —— 写成箭头函数把它变成显式的。
插入位置:紧接在 onMounted(() => loadData()); 之前(:139 处),此时 query / loadData 都已定义。
// 退款记录页:只听 lsy_refund。本页没有勾选/弹窗/行内操作 ⇒ 不传 busy。
// 退款的写入信号来自后端同一条广播(lsy_refund 通道),订单页与这一页各订各的。
useLiveRefetch(["lsy_refund"], () => loadData(true));
导入(加到 :88 那一组里):
import { useLiveRefetch } from "@/composables";
[ ] Step 5: 差分验证
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: 提交
git add frontend/src/views/module_payment/collection/refund/index.vue
git commit -m "feat(订单实时): 退款记录页接线 —— lsy_refund 单通道静默重拉"
Files:
frontend/src/views/module_payment/har/order/index.vueInterfaces:
useLiveRefetch(Task 9)、HarAPI.orderList(params, silent=false) / HarAPI.orderDetail(id, silent=false)(Task 10)这一页是三条接线里最危险的一条:分页事件的处理器是裸函数名(:211 @size-change="load"、:212 @current-change="load"),而 el-pagination 这两条事件都会把新页码/新页长当作第一个实参传进来。不加箭头函数的话 silent 会永远拿到一个数字。
load 加静默参数:566-586:
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)改成箭头函数
@size-change="() => load()"
@current-change="() => load()"
v-model:current-page / v-model:page-size 在事件回调之前就写好了 ref,所以箭头函数里 load() 读到的是新页码 ✓。
插入位置:紧接在 onMounted(async () => { 之前(:772 处)。此时 createVisible / saving(:492-493)、detailVisible / detail(:496-497)都已声明。
// ─────────────────── 实时联动(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:
import { onMounted, reactive, ref, computed } from "vue";
再加一行:
import { useLiveRefetch } from "@/composables";
confirmingRefundhandleRefund 里 let reason = ""; try { const { value } = await ElMessageBox.prompt(…) … } catch { return; } 改成:
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: 差分验证
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 里。
通道名是前后端之间唯一的字符串契约(后端 OrderEventPublisher.CHANNEL_* ↔ 前端 useLiveRefetch 的第一个实参)。拼错的表现是「某页永远不刷新」且没有任何报错。
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: 提交
git add frontend/src/views/module_payment/har/order/index.vue
git commit -m "feat(订单实时): HAR 订单页接线 —— har_order 单通道、分页裸引用改箭头函数、详情联动"
Files:
Interfaces:
前置:库隧道 localhost:5432 在线;后端 8001、前端 dev(pnpm dev,5180)能起;浏览器可登 q(tenant 2)。
[ ] Step 1: 后端全量测试
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 + 指纹
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: 前端构建 + 产物证明
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 断言(不依赖浏览器,最能定案的一组)
# ① 无 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 保证抓得到一帧)。
终端 A(挂着收帧,60 秒):
curl -sN -H "Authorization: Bearer $TOKEN" --max-time 60 \
http://localhost:8001/api/v1/payment/stream/orders > /tmp/sse-body.txt
终端 B(触发一次真实写入 —— 收银台下单,1 元,最小金额;amount 单位是分):
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 的浏览器断言把它变成必红用例)。
逐条记录实际看到的东西(截图/原始响应),不要只记「通过」:
stream/orders 恰好 1 条且长期 pending;切到退款记录页、HAR 订单页 → 仍是那 1 条(模块级单例;页面切换不新增连接)。total 与 Network 里 /order/page 的请求数 → 让 tenant 27 的一笔单变化 → q 的页面纹丝不动(total 不变、不出现新的 /order/page 请求)。正控必须先量:用 q 自己的令牌下一单 → q 的页面必须变 —— 「不变」只有在「会变」被量到之后才算证据。
.el-message(toast)数量为 0;页面没有任何新元素/角标/状态点(与改前截图对比)。total 与首行不变)→ 取消勾选 → 无需操作,1 秒内列表自己补上。pending,Network 里 stream/orders 仍只有 1 条(本机经 Vite 代理;生产 nginx 的缓冲只能部署后验,见 Review Focus ⑤)。把 Step 1-3 的三组指纹(测试汇总行 / jar 名+字节+mtime / dist 指纹)与 Step 4-6 的实测结果一起贴出来。提交已经按任务做完了;是否 push、是否部署(重打 jar + dist 上线)由用户拍板 —— 参考本仓的一条既有教训:没提交的交付等于没交付,而这些提交目前仍在本地 java 分支上。
cd /d/project2/payment-platform && git log --oneline -14
mvn test 全绿(本次 stdout 汇总行)pnpm build exit 0,且三个通道名出现在 dist/assetsevent: ping 与 data: {"channel":"lsy_order"}.el-message 数为 0git log --oneline 可见每个任务各自的提交)