2026-10-05-order-realtime-push.md 136 KB

订单实时推送(SSE 长连接)实现计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: lsy 支付订单页、lsy 退款记录页、HAR 订单页在服务端任何一次落库后 1 秒内自动更新,用户零感知(无状态点、无提示、无 toast、无新增按钮)。

Architecture: 服务端把每条 SseEmitter 注册进进程内 Hub(超管桶 + 租户桶),写库方在行真的改了之后调 OrderEventPublisher.changed(通道, 租户);事件经 @TransactionalEventListener(AFTER_COMMIT, fallbackExecution=true) 落到 Hub,由唯一一条名为 order-sse 的守护线程串行广播 {"channel":"..."} 帧。前端模块级单例维持一条 EventSource,收到帧后由 useLiveRefetch 走既有列表接口重拉 —— 租户隔离、分页、筛选、权限全部免费复用。

Tech Stack: Spring Boot 3(SseEmitter 在 spring-web 内,零新依赖)、Spring ApplicationEventPublisher、Java 21(record 可用)、Vue 3 组合式 API、原生 EventSource、axios 拦截器、esbuild + node 假件脚本(本仓前端零测试框架,见 Task 8 的量具说明)。

Spec: docs/superpowers/specs/2026-10-05-order-realtime-push-design.md(238 行,已提交 8632bf55)。本计划从它论证;不一致处以下方「与已批 spec 的偏差」为准。

任务总览(14 个任务,每个任务一次 commit,B=后端 F=前端):

# 任务 交付物
1 OrderEventHub 进程内广播 + 心跳 + 单线程串行化
2 事件三件套 OrderChangedEvent / OrderEventPublisher / OrderEventRelay
3 SSE 端点 GET /api/v1/payment/stream/orders
4 lsy 订单状态收口点 applyPayResult / applyRefundResult 两个口 + 6 参构造波及 5 个测试类
5 lsy 订单表其余写点 8 处落点 / 10 行发布(建单 5 + 关单·撤销·过期 5)+ 审计回归
6 lsy 退款表写点 退款 insert + 3 处 update + 审计回归
7 har 订单表写点 6 处写点 + 审计回归
8 前端 orderStream EventSource 单例(退避/看门狗/重连补拉)+ node 验证脚本
9 前端 useLiveRefetch 挂起/补刷/合并 + node 验证脚本
10 前端管线 request.ts 静默开关 + 3 个 API 透传 + 登出停流 + 删死文件
11 支付订单页接线 两通道 + busy + 详情联动
12 退款记录页接线 一通道 + 静默重拉(无 busy,本页无操作态)
13 HAR 订单页接线 一通道 + busy(创建/提交/退款确认框)+ 详情联动
14 端到端验收 + 构建 真服务 E2E + jar/dist

Global Constraints

  • 四个含凭据的文件绝不能碰:java/src/main/resources/application.yml、deploy.sh、docker-compose.yml、.claude/plan/ops-manual.md(读取会撞权限闸)。本计划不需要改任何配置项——SSE 无开关、无新配置键。
  • 提交只走显式路径,永不 git add -A;提交信息见各任务 Step,风格照本仓 feat(订单实时): ...。
  • 禁用 sed -i(会压平 CRLF,仓库 autocrlf=true);改文件一律 Edit/Write。
  • 不得猜测或捏造:计划里的行号是 2026-10-05 的读数;文件被动过之后以锚点文本为准,行号只作路标。
  • 后端测试命令:cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test(mvn 用绝对路径,mvnw 是坏的;不要加 -proc:none / -q;控制台 GBK;mvn test 需要库隧道 localhost:5432 在线)。单类:-Dtest=类名。target/surefire-reports/ 跨运行累积 ⇒ 只读本次 stdout 的 Tests run: 汇总行,别把旧报告当本次读数。
  • 前端检查:cd frontend && pnpm ts:check —— 基线恒 exit 2(历史遗留类型错),只能「同机差分」用(先存基线输出,跑完 diff,看有没有新增的 文件(行,列));更硬的出口是 pnpm build。任何变异验证都必须在 frontend/ 目录里跑。
  • 新增依赖:零。 前端 package.json 不动,后端 pom.xml 不动(SseEmitter 在 spring-boot-starter-web 里)。
  • 新字段一律加在字段块末尾(构造器实参表尾追加)—— 现有 14 个测试类手工 new 这些类,只有尾追加才不用重排实参。
  • 断言用 JUnit 5 Jupiter(org.junit.jupiter.api.Assertions,全仓 79 个测试类用它);本仓没有 AssertJ(0 个类),不要写 assertThat。
  • 版本:Spring Boot 3.x(jakarta.* 注解,如 jakarta.annotation.PostConstruct)、Java 21。

与已批 spec 的偏差(4 条,需你审计划时确认)

  1. 详情抽屉/详情弹窗不挂起。 spec 的 busy 表把「详情抽屉」「详情弹窗」列入挂起,但同一节又承诺「详情实时联动」—— 两者互斥。本计划取详情实时、只有勾选态与操作弹窗(批量退款、退款确认、创建表单)挂起。理由:主用例就是「收银台盯着一个未付单等它翻 SUCCESS」,挂起详情会砍掉这个用例。改动只影响 spec 表格一行。
  2. 不写 Spring 容器级测试(spec 的「事务二分」改形态)。 全仓 81 个测试类无一带 @SpringBootTest(现状读数),拉一个起来会撞 leshouying.enabled 启动闸 + 库隧道依赖。拆成两半验:「提交后才投递」= relay 的委托单测 + 注解断言(Task 2 把 AFTER_COMMIT/fallbackExecution 钉在测试里)+ 真有事务的开放 API 建单腿 E2E(Task 14:建单后 1 秒内页面出现新行;若信号早于提交,重拉看不到新行 ⇒ 用例必红)。
  3. 未登录状态码:spec 写「无 token → 401」,本计划按 403 写 —— 依据是源码:SecurityConfig 没配 entryPoint ⇒ 匿名被拒时走 Spring Security 的默认 Http403ForbiddenEntryPoint(403);而 401 是 JwtAuthFilter 自己抛的(坏 token 才走那条)。Task 3 的测试按 403 写(它测的是 controller 在 SecurityContext 里取不到用户时的防御分支)。真实链路上的 403/401 由 Task 14 Step 4 当场 curl 量;量出来若不是这两个数,改的是那条断言与这句说明,不是改代码去迎合文档。
  4. 端点路径与类名统一挂到 stream/ 命名空间(spec 里是另一套名字):spec 写 GET /api/v1/payment/order-events、OrderEventController、OrderChangedRelay;本计划写 GET /api/v1/payment/stream/orders、OrderStreamController、OrderEventRelay。理由:实时相关的东西收在一个包/一个前缀下,与前端唯一那个 src/utils/orderStream.ts 一一对应,日后扩容(换 Redis 广播器)也在同一处。计划里的名字是实施口径;若你更认 spec 的原名,现在说 —— 要改的是计划里 13 处 URL + 3 个类名。同类差异还有一处:spec 写 useLiveRefetch({ channels, busy, refetch })(对象参数),本计划写 useLiveRefetch(channels, refetch, options)(位置参数,见 Task 9 的 LiveRefetchOptions),同样以计划为准。

Review Focus

按 spec 措辞实现完之后,最可能让人踩坑的五件事(每条都配了测试,括号是责任任务):

  1. 同一条 emitter 被并发 send(心跳与广播撞上)——SseEmitter 不允许多线程并发写,症状是偶发 IllegalStateException、帧交叠、连接莫名断。→ Task 1 生产路径的每一帧都在order_sse线程上发出(8 线程并发提交 + 线程名断言)。
  2. 信号早于事务提交(开放 API 建单腿有事务)——页面收到信号、重拉,看不到那行新数据:比不推更糟。→ Task 2 注解断言 + 委托测试;Task 14 真建单 E2E。
  3. 登出后前端还攥着连接(token 已清,SSE 反复被 403 挡回、无限重连)→ Task 8 node 脚本的 stopOrderStream 用例 + Task 10 的 resetAllState 接线。
  4. 静默刷新搅掉用户正在做的事(勾选被清空、弹窗下的列表跳页、详情抽屉内容被清)→ Task 11/12/13 的 busy 接线 + Task 14 的挂起 E2E(勾选中 → 触发写入 → 列表不动;取消勾选 → 静默补刷)。
  5. 生产 nginx 缓冲吞掉 ping(静置 2 分钟被 proxy_read_timeout 掐断,页面从此不再更新)→ Task 3 的 X-Accel-Buffering: no 响应头 + Task 14 的 ≥2 分钟静置用例(本机直连测不出缓冲,这条只能部署后验;E2E 先验 ping 帧本身按时到达)。

文件结构

后端新增(新包 com.payment.platform.module.payment.stream,4 个类 + 3 个测试):

  • OrderEventHub.java — 桶、心跳、单线程串行化(Task 1)
  • OrderChangedEvent.java / OrderEventPublisher.java / OrderEventRelay.java(Task 2)
  • OrderStreamController.java(Task 3)
  • src/test/java/.../stream/OrderEventHubTest.java / OrderEventPublisherTest.java / OrderEventRelayTest.java / OrderStreamControllerTest.java

后端修改(只加字段 + 发行,不动既有逻辑):

  • Task 4:leshouying/service/LsyOrderService.java
  • Task 5:leshouying/controller/LeshouyingCashierController.java、LeshouyingCheckoutController.java、controller/open/LeshouyingOpenController.java、scheduler/LsyOrderPollScheduler.java、scheduler/LsyReconcileScheduler.java
  • Task 6:leshouying/service/LsyRefundService.java
  • Task 7:har/service/HarOrderService.java、HarNotifyService.java、HarCallbackService.java
  • 测试侧:Task 4 一次性修完 LsyOrderService 的 5 个手工构造点;Task 5 再修 6 个测试类里的构造点(仅 LsySchedulerTest 一个类就有 14 处:new LsyOrderPollScheduler( ×7 + new LsyReconcileScheduler( ×7)。两份清单分别在两个任务的 Files 里,行号以当场 grep -n 为准

前端:

  • 新增 src/utils/orderStream.ts(工厂 + 单例,Task 8)、src/composables/useLiveRefetch.ts(Task 9)、scripts/verify-order-stream.mjs(Task 8)、scripts/verify-live-refetch.mjs(Task 9)
  • 修改 src/utils/request.ts、src/api/module_payment/collection.ts、src/api/module_payment/har.ts、src/store/modules/user.store.ts(Task 10)
  • 修改 src/views/module_payment/collection/order/index.vue(Task 11)、collection/refund/index.vue(Task 12)、har/order/index.vue(Task 13)
  • 删除 src/utils/websocket.ts(零引用死文件,Task 10,删前重验)

Task 1: 后端 —— OrderEventHub(进程内广播 + 心跳 + 唯一一条 order-sse 线程)

Files:

  • Create: java/src/main/java/com/payment/platform/module/payment/stream/OrderEventHub.java
  • Test: java/src/test/java/com/payment/platform/module/payment/stream/OrderEventHubTest.java

Interfaces:

  • Consumes: 无(不依赖本仓任何既有代码)
  • Produces(后续所有后端任务都按这套签名调):

    • public SseEmitter register(Long tenantId, boolean superAdmin)
    • public void submitBroadcast(String channel, Long tenantId) — 生产路径(提交到 order-sse 线程)
    • public void broadcast(String channel, Long tenantId) — 同步版(调用线程直接发,测试用)
    • protected boolean sendFrame(SseEmitter, String channel) / protected void sendRetry(SseEmitter) / protected boolean sendPing(SseEmitter) — 测试覆写点
    • 常量 TIMEOUT_MILLIS = 1 小时、PING_INTERVAL_SECONDS = 20、RECONNECT_TIME_MILLIS = 3000
    • 包级访问器 connectionCount() / tenantConnectionCount(long) / tenantBucketCount()(仅测试用,生产代码不许调)
  • [ ] Step 1: 写失败的测试

创建 java/src/test/java/com/payment/platform/module/payment/stream/OrderEventHubTest.java:

package com.payment.platform.module.payment.stream;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

import static org.junit.jupiter.api.Assertions.*;

/**
 * Hub 的桶路由与线程模型。测试里**不碰真 emitter**(没有 HTTP 响应可写):
 * RecordingHub 覆写全部 send 出口记账,SseEmitter 只当身份令牌用。
 */
class OrderEventHubTest {

    private final RecordingHub hub = new RecordingHub();

    @AfterEach
    void tearDown() {
        hub.shutdown();      // 每个用例都要收:Hub 构造时起了执行器与心跳,不收会攒线程
    }

    @Test
    void broadcast_routesOnlyToTheSameTenant() {
        SseEmitter t1 = hub.register(1L, false);
        SseEmitter t2 = hub.register(2L, false);

        hub.broadcast("lsy_order", 1L);

        assertEquals(List.of("lsy_order"), hub.framesOf(t1));
        assertTrue(hub.framesOf(t2).isEmpty(), "别的租户一帧都不该收到");
    }

    @Test
    void broadcast_reachesSuperAdminForAnyTenant() {
        SseEmitter admin = hub.register(null, true);

        hub.broadcast("har_order", 2L);

        assertEquals(List.of("har_order"), hub.framesOf(admin));
    }

    @Test
    void register_superAdminWithTenantId_doesNotDoubleReceive() {
        // 超管也可能带 tenantId(UserDetailsServiceImpl 把 sys_user.tenant_id 原样搬进 LoginUser)。
        // 两条桶都收他 = 一条连接同帧收两次 = 页面同秒重拉两次。
        SseEmitter admin = hub.register(2L, true);

        hub.broadcast("lsy_order", 2L);

        assertEquals(List.of("lsy_order"), hub.framesOf(admin));
    }

    @Test
    void register_nonSuperAdminWithoutTenant_getsNothing() {
        SseEmitter nobody = hub.register(null, false);

        hub.broadcast("lsy_order", 1L);

        assertTrue(hub.framesOf(nobody).isEmpty());
        assertEquals(0, hub.connectionCount(), "无租户的非超管不进任何桶(保守:宁可少推)");
    }

    @Test
    void broadcast_sendFailure_removesConnectionAndEmptyBucket() {
        SseEmitter dead = hub.register(3L, false);
        hub.failFor.put(dead, true);

        hub.broadcast("lsy_refund", 3L);

        assertEquals(0, hub.connectionCount(), "发不出去的连接要立刻摘掉");
        assertEquals(0, hub.tenantBucketCount(), "空桶要删 key,否则每个登录过的租户永远留一个空 List");
    }

    @Test
    void submitBroadcast_everyFrameGoesOutOnTheOrderSseThread() throws Exception {
        SseEmitter t1 = hub.register(1L, false);
        hub.expectFrames(8);
        ExecutorService callers = Executors.newFixedThreadPool(8);
        try {
            for (int i = 0; i < 8; i++) {
                callers.execute(() -> hub.submitBroadcast("lsy_order", 1L));
            }
            assertTrue(hub.awaitFrames(5, TimeUnit.SECONDS), "8 次提交应在 5 秒内出完 8 帧");
        } finally {
            callers.shutdownNow();
        }
        assertEquals(8, hub.framesOf(t1).size());
        assertEquals(List.of("order-sse"), hub.frameThreads.stream().distinct().toList(),
                "所有 send 必须落在同一条 order-sse 线程上 —— SseEmitter 禁并发 send,"
                        + "这条红了说明有人在调用线程上直接发了");
    }

    /** 覆写全部 send 出口:只记账,不碰真 emitter。 */
    static class RecordingHub extends OrderEventHub {
        final Map<SseEmitter, List<String>> sent = new ConcurrentHashMap<>();
        final Map<SseEmitter, Boolean> failFor = new ConcurrentHashMap<>();
        final List<String> frameThreads = new CopyOnWriteArrayList<>();
        private volatile CountDownLatch latch = new CountDownLatch(0);

        void expectFrames(int n) {
            latch = new CountDownLatch(n);
        }

        boolean awaitFrames(long timeout, TimeUnit unit) throws InterruptedException {
            return latch.await(timeout, unit);
        }

        List<String> framesOf(SseEmitter emitter) {
            return sent.getOrDefault(emitter, List.of());
        }

        @Override
        protected boolean sendFrame(SseEmitter emitter, String channel) {
            if (Boolean.TRUE.equals(failFor.get(emitter))) {
                return false;
            }
            sent.computeIfAbsent(emitter, k -> new CopyOnWriteArrayList<>()).add(channel);
            frameThreads.add(Thread.currentThread().getName());
            latch.countDown();
            return true;
        }

        @Override
        protected void sendRetry(SseEmitter emitter) {
            // 注册时的 retry 帧不记账:它不属于任何通道,计进去会打乱帧计数
        }

        @Override
        protected boolean sendPing(SseEmitter emitter) {
            return true;   // 心跳在单测里是噪声(20s 才来一次,用例早结束了)
        }
    }
}
  • Step 2: 跑它,确认红

Run: cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest=OrderEventHubTest Expected: 编译失败 cannot find symbol: class OrderEventHub(Java 里新类没建就是「函数不存在」的等价红)。

  • Step 3: 写实现

创建 java/src/main/java/com/payment/platform/module/payment/stream/OrderEventHub.java:

package com.payment.platform.module.payment.stream;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;

/**
 * 订单实时信号的进程内广播器(部署形态是单实例,见 spec)。
 *
 * <p>桶模型:超管一桶 + 租户各一桶。超管**只进超管桶** —— 同一条连接收两份帧既是浪费、
 * 又会让页面同一秒重拉两次。非超管且 tenantId 为 null:不进任何桶(保守,宁可少推)。
 *
 * <p>线程模型:对 emitter 的**每一次写**(注册时的 retry 帧、广播帧、心跳 ping)都提交到
 * 同一条名为 {@code order-sse} 的守护线程上串行执行 —— {@code SseEmitter} 不允许多线程
 * 并发 send。这条线程是自建的,**不是** Spring 的调度池:{@code spring.task.scheduling.pool.size}
 * 默认 1、支付对账调度器就挂在上面,占用它会把对账挤停。
 */
@Slf4j
@Component
public class OrderEventHub {

    /** emitter 超时:显式 1 小时。不赌 0L 的语义;到点自然断,前端重连 + 补拉接住。 */
    public static final long TIMEOUT_MILLIS = TimeUnit.HOURS.toMillis(1);
    /** 心跳间隔 20s:nginx proxy_read_timeout 默认 60s,留 3 倍余量。 */
    public static final long PING_INTERVAL_SECONDS = 20;
    /** 重连提示:浏览器断线后 3s 再来(首次连接不受影响)。 */
    public static final long RECONNECT_TIME_MILLIS = 3000L;

    private final List<SseEmitter> superAdmins = new CopyOnWriteArrayList<>();
    private final Map<Long, List<SseEmitter>> byTenant = new ConcurrentHashMap<>();

    private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(r -> {
        Thread t = new Thread(r, "order-sse");
        t.setDaemon(true);
        return t;
    });
    private ScheduledFuture<?> heartbeat;

    /**
     * 注册一条连接。返回的 emitter 交给 Spring MVC 接管响应。
     *
     * @param tenantId   登录用户的租户;可为 null
     * @param superAdmin is_superuser 开关(与 TenantInnerInterceptor 用的是同一个)
     */
    public SseEmitter register(Long tenantId, boolean superAdmin) {
        SseEmitter emitter = new SseEmitter(TIMEOUT_MILLIS);
        List<SseEmitter> bucket = superAdmin ? superAdmins
                : (tenantId == null ? null : byTenant.computeIfAbsent(tenantId, k -> new CopyOnWriteArrayList<>()));
        if (bucket == null) {
            // 无租户的非超管:收不到任何信号,立刻正常结束,别让客户端挂满 1 小时
            log.info("[订单SSE] 无租户的非超管登录,不建连接");
            emitter.complete();
            return emitter;
        }
        // ★ spec 的「实现前必须先验证的小项」里有一条:非超管 + tenantId=null 在
        //   TenantInnerInterceptor 下到底看不看得见那些行。**这一条没有做**,如实记在这里:
        //   它改不了本通道的行为 —— 不入桶就是不推,而这位用户取数一直走列表接口,
        //   加通道前后完全一致(不回归、也不泄露)。哪天真要给他推,改的是上面这个分支 + 一条用例。
        //   别把它写成「已验证」。
        emitter.onCompletion(() -> remove(bucket, emitter));
        emitter.onTimeout(() -> remove(bucket, emitter));
        emitter.onError(e -> remove(bucket, emitter));
        bucket.add(emitter);
        // retry 帧在 emitter 初始化前 send 会进容器的 earlySendAttempts 队列、由 initialize 时补发;
        // 仍排到同一条线程上,保持「所有写都在 order-sse」这条不变式
        executor.execute(() -> sendRetry(emitter));
        return emitter;
    }

    /** 同步广播:调用线程直接发。生产别用(见 {@link #submitBroadcast}),测试用。 */
    public void broadcast(String channel, Long tenantId) {
        deliver(superAdmins, channel);
        if (tenantId != null) {
            List<SseEmitter> bucket = byTenant.get(tenantId);
            if (bucket != null) {
                deliver(bucket, channel);
            }
        }
    }

    /** 生产路径:提交到 order-sse 单线程上执行,与心跳串行。 */
    public void submitBroadcast(String channel, Long tenantId) {
        executor.execute(() -> broadcast(channel, tenantId));
    }

    private void deliver(List<SseEmitter> bucket, String channel) {
        for (SseEmitter emitter : bucket) {
            if (!sendFrame(emitter, channel)) {
                remove(bucket, emitter);
            }
        }
    }

    private void remove(List<SseEmitter> bucket, SseEmitter emitter) {
        bucket.remove(emitter);
        if (bucket != superAdmins && bucket.isEmpty()) {
            // 空桶删 key:否则每个登录过的租户都留一个空 List 到进程结束
            byTenant.values().remove(bucket);
        }
    }

    /** 帧格式:有名事件 + 一个 JSON 对象。**不带数据** —— 页面拿信号去重拉它自己的列表接口。 */
    protected boolean sendFrame(SseEmitter emitter, String channel) {
        try {
            emitter.send(SseEmitter.event().name("changed").data(Map.of("channel", channel)));
            return true;
        } catch (Exception e) {
            log.debug("[订单SSE] 发送失败,摘除连接: channel={}, err={}", channel, e.getMessage());
            return false;
        }
    }

    protected void sendRetry(SseEmitter emitter) {
        try {
            emitter.send(SseEmitter.event().reconnectTime(RECONNECT_TIME_MILLIS));
        } catch (Exception e) {
            // 注册瞬间客户端可能已经走了:忽略,onError/onCompletion 会清
            log.debug("[订单SSE] 重连提示发送失败: {}", e.getMessage());
        }
    }

    /** 心跳:有名事件 ping(注释帧 JS 看不见,前端看门狗就废了)。 */
    protected boolean sendPing(SseEmitter emitter) {
        try {
            emitter.send(SseEmitter.event().name("ping").data("{}"));
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    @PostConstruct
    void startHeartbeat() {
        heartbeat = executor.scheduleAtFixedRate(this::pingAll,
                PING_INTERVAL_SECONDS, PING_INTERVAL_SECONDS, TimeUnit.SECONDS);
        log.info("[订单SSE] 广播器就绪(单线程 order-sse,心跳 {}s)", PING_INTERVAL_SECONDS);
    }

    private void pingAll() {
        pingBucket(superAdmins);
        for (List<SseEmitter> bucket : byTenant.values()) {
            pingBucket(bucket);
        }
    }

    private void pingBucket(List<SseEmitter> bucket) {
        for (SseEmitter emitter : bucket) {
            if (!sendPing(emitter)) {
                remove(bucket, emitter);
            }
        }
    }

    @PreDestroy
    void shutdown() {
        if (heartbeat != null) {
            heartbeat.cancel(false);
        }
        executor.shutdownNow();
    }

    /** 仅测试用:当前连接总数。 */
    int connectionCount() {
        int n = superAdmins.size();
        for (List<SseEmitter> bucket : byTenant.values()) {
            n += bucket.size();
        }
        return n;
    }

    /** 仅测试用。 */
    int tenantConnectionCount(long tenantId) {
        List<SseEmitter> bucket = byTenant.get(tenantId);
        return bucket == null ? 0 : bucket.size();
    }

    /** 仅测试用:还留着几个租户桶(空桶应被删掉)。 */
    int tenantBucketCount() {
        return byTenant.size();
    }
}
  • Step 4: 跑它,确认绿

Run: cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest=OrderEventHubTest Expected: Tests run: 6, Failures: 0, Errors: 0, Skipped: 0

  • Step 5: 变异验证(两向:该红的必须红,还原后必须仍绿)

逐个做,每个做完还原再跑一次确认仍绿:

变异(只改一处) 期望变红的用例
broadcast 里 deliver(superAdmins, ...) 后面加一句对所有 byTenant.values() 投递(忽略 tenantId 路由) broadcast_routesOnlyToTheSameTenant
submitBroadcast 改成直接 broadcast(channel, tenantId)(去掉执行器) submitBroadcast_everyFrameGoesOutOnTheOrderSseThread(frameThreads 里会出现 pool-N-thread-M)
register 里超管也 byTenant.computeIfAbsent(...) 加一份 register_superAdminWithTenantId_doesNotDoubleReceive
remove 里删掉空桶清理那一句 broadcast_sendFailure_removesConnectionAndEmptyBucket

任一变异没有让对应用例变红 ⇒ 停手,说明那条断言没有真的量到东西,先修测试。

  • [ ] Step 6: 提交

    git add java/src/main/java/com/payment/platform/module/payment/stream/OrderEventHub.java \
        java/src/test/java/com/payment/platform/module/payment/stream/OrderEventHubTest.java
    git commit -m "feat(订单实时): 新增订单事件广播 Hub —— SSE 桶 + 心跳 + 单线程串行"
    

Task 2: 后端 —— 事件三件套(事件 / 发布器 / 提交后转发)

Files:

  • Create: java/src/main/java/com/payment/platform/module/payment/stream/OrderChangedEvent.java
  • Create: java/src/main/java/com/payment/platform/module/payment/stream/OrderEventPublisher.java
  • Create: java/src/main/java/com/payment/platform/module/payment/stream/OrderEventRelay.java
  • Test: java/src/test/java/com/payment/platform/module/payment/stream/OrderEventPublisherTest.java
  • Test: java/src/test/java/com/payment/platform/module/payment/stream/OrderEventRelayTest.java

Interfaces:

  • Consumes: OrderEventHub.submitBroadcast(String, Long)(Task 1)
  • Produces(Task 4-7 的每一个发布点都长这样一行):

    • public void OrderEventPublisher.changed(String channel, Long tenantId)
    • 常量 OrderEventPublisher.CHANNEL_LSY_ORDER = "lsy_order" / CHANNEL_LSY_REFUND = "lsy_refund" / CHANNEL_HAR_ORDER = "har_order"(与前端脚本钉的是同一组串)
    • public record OrderChangedEvent(String channel, Long tenantId)
  • [ ] Step 1: 写失败的测试

创建 OrderEventPublisherTest.java:

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 = 乐收赢那些刻意无事务的写库腿一帧都发不出去(静默失效)");
    }
}
  • Step 2: 跑它,确认红

Run: cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest='OrderEventPublisherTest+OrderEventRelayTest' Expected: 编译失败 cannot find symbol: class OrderEventPublisher / OrderChangedEvent / OrderEventRelay

  • Step 3: 写实现

创建 OrderChangedEvent.java:

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());
    }
}
  • Step 4: 跑它,确认绿

Run: cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest='OrderEventPublisherTest+OrderEventRelayTest' Expected: Tests run: 5, Failures: 0, Errors: 0, Skipped: 0

  • Step 5: 变异验证
变异 期望变红
relay 的注解 fallbackExecution = true 改成默认(删掉参数) theAfterCommitSemanticsArePinnedInCode
relay 的 phase 改成 BEFORE_COMMIT 同上
OrderEventPublisher.CHANNEL_LSY_REFUND 改成 "lsy_refunds" channelNames_areTheWireContractWithFrontend
  • [ ] Step 6: 提交

    git add java/src/main/java/com/payment/platform/module/payment/stream/OrderChangedEvent.java \
        java/src/main/java/com/payment/platform/module/payment/stream/OrderEventPublisher.java \
        java/src/main/java/com/payment/platform/module/payment/stream/OrderEventRelay.java \
        java/src/test/java/com/payment/platform/module/payment/stream/OrderEventPublisherTest.java \
        java/src/test/java/com/payment/platform/module/payment/stream/OrderEventRelayTest.java
    git commit -m "feat(订单实时): 领域事件三件套 —— 提交后转发(无事务兜底)"
    

Task 3: 后端 —— SSE 订阅端点

Files:

  • Create: java/src/main/java/com/payment/platform/module/payment/stream/OrderStreamController.java
  • Test: java/src/test/java/com/payment/platform/module/payment/stream/OrderStreamControllerTest.java

Interfaces:

  • Consumes: OrderEventHub.register(Long, boolean)(Task 1)
  • Produces: GET /api/v1/payment/stream/orders(text/event-stream,前端 Task 8 就调这个 URL)
    • 查询参数 ?token=<access_token>:EventSource 不能自定义请求头,鉴权靠 JwtAuthFilter.extractToken 对 ?token= 的现成支持
    • 响应头 X-Accel-Buffering: no + Cache-Control: no-cache

安全说明(不许省):本端点不加 @PreAuthorize —— 任何登录用户都可以订阅,但推给他的帧只属于他自己的租户桶(超管走超管桶)。即使有人伪造租户也无意义:帧里只有通道名、没有数据,重拉走的是带租户过滤的列表接口。SecurityConfig 的 anyRequest().authenticated() 已经覆盖这个新路径,SecurityConfig 不需要改。取当前用户的写法照抄本仓既有惯用法(SecurityContextHolder + instanceof LoginUser,见 LeshouyingCashierController#requireTenantId 附近;本仓 0 处 @AuthenticationPrincipal,别新造一个)。

  • Step 1: 写失败的测试

创建 OrderStreamControllerTest.java:

package com.payment.platform.module.payment.stream;

import com.payment.platform.common.exception.BusinessException;
import com.payment.platform.core.security.LoginUser;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

class OrderStreamControllerTest {

    private final OrderEventHub hub = new OrderEventHub();
    private final OrderStreamController controller = new OrderStreamController(hub);
    private final MockHttpServletResponse response = new MockHttpServletResponse();

    @AfterEach
    void tearDown() {
        SecurityContextHolder.clearContext();
        hub.shutdown();
    }

    private static void login(Long tenantId, boolean superuser) {
        LoginUser user = new LoginUser();
        user.setTenantId(tenantId);
        user.setIsSuperuser(superuser);
        SecurityContextHolder.getContext().setAuthentication(
                new UsernamePasswordAuthenticationToken(user, null, List.of()));
    }

    @Test
    void orders_tenantUser_opensOneTenantConnectionAndDeclaresNoBuffering() {
        login(2L, false);

        SseEmitter emitter = controller.orders(response);

        assertNotNull(emitter);
        assertEquals("no", response.getHeader("X-Accel-Buffering"),
                "不写这条头,nginx 会把 SSE 帧攒着转发,页面看起来「不实时」");
        assertEquals(1, hub.connectionCount());
        assertEquals(1, hub.tenantConnectionCount(2L));
    }

    @Test
    void orders_superAdmin_goesToSuperAdminBucketOnly() {
        login(2L, true);

        controller.orders(response);

        assertEquals(1, hub.connectionCount());
        assertEquals(0, hub.tenantConnectionCount(2L), "超管不进租户桶 —— 否则同帧收两份");
    }

    @Test
    void orders_anonymous_throws403AndOpensNothing() {
        BusinessException ex = assertThrows(BusinessException.class, () -> controller.orders(response));

        assertTrue(ex.getMessage().contains("未登录"), "实际文案=" + ex.getMessage());
        assertEquals(0, hub.connectionCount(), "匿名不许往桶里挂空连接");
    }
}
  • Step 2: 跑它,确认红

Run: cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest=OrderStreamControllerTest Expected: 编译失败 cannot find symbol: class OrderStreamController

  • Step 3: 写实现

创建 OrderStreamController.java:

package com.payment.platform.module.payment.stream;

import com.payment.platform.common.exception.BusinessException;
import com.payment.platform.core.security.LoginUser;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

/**
 * 订单实时信号长连接(SSE)。
 *
 * <p>身份取自 Security 上下文(与全仓 6 个控制器同一惯用法)。EventSource 不能自定义请求头,
 * 所以浏览器那条腿靠 {@code JwtAuthFilter} 对 {@code ?token=} 的现成支持;服务端不额外做令牌轮询
 * (滑动过期下 JWT 的 exp 不是权威源)。
 */
@Slf4j
@RestController
@RequestMapping("/api/v1/payment/stream")
@RequiredArgsConstructor
public class OrderStreamController {

    private final OrderEventHub hub;

    @GetMapping(value = "/orders", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter orders(HttpServletResponse response) {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        LoginUser user = (auth != null && auth.getPrincipal() instanceof LoginUser u) ? u : null;
        if (user == null) {
            // anyRequest().authenticated() 已经挡在前面;这里是最后一道,免得「匿名却通过了」
            // 时往桶里挂一条谁都收不到的空连接
            throw new BusinessException(403, "未登录,无法建立实时连接");
        }
        // nginx 默认会缓冲上游响应:不写这条头,帧会被攒着 —— 本机直连看不出来,部署后才暴露
        response.setHeader("X-Accel-Buffering", "no");
        response.setHeader("Cache-Control", "no-cache");
        return hub.register(user.getTenantId(), Boolean.TRUE.equals(user.getIsSuperuser()));
    }
}
  • Step 4: 跑它,确认绿

Run: cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest='OrderStreamControllerTest' Expected: Tests run: 3, Failures: 0, Errors: 0, Skipped: 0

  • Step 5: 全量回归(确认没有波及)

Run: cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test Expected: 本次 stdout 汇总行 Tests run: <N>, Failures: 0, Errors: 0, Skipped: 0(N = 既有 81 个类 + 新增 4 个类;读汇总行,不要读 surefire-reports 目录——它跨运行累积)

  • Step 6: 变异验证
变异 期望变红
Boolean.TRUE.equals(user.getIsSuperuser()) 改成 true(人人当超管) orders_tenantUser_opensOneTenantConnectionAndDeclaresNoBuffering
去掉 response.setHeader("X-Accel-Buffering", "no") 同上的第二条断言
user == null 分支改成 hub.register(null, false) 继续往下走 orders_anonymous_throws403AndOpensNothing
  • [ ] Step 7: 提交

    git add java/src/main/java/com/payment/platform/module/payment/stream/OrderStreamController.java \
        java/src/test/java/com/payment/platform/module/payment/stream/OrderStreamControllerTest.java
    git commit -m "feat(订单实时): SSE 订阅端点 /payment/stream/orders(复用 ?token= 鉴权)"
    

Task 4: lsy 订单状态收口点发布(含 6 参构造波及)

Files:

  • Modify: java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOrderService.java(字段块末尾 + 两处 return true; 之前)
  • Modify(仅构造点):LsyOrderServiceStateTest.java:43、LsyBarcodePayTest.java:37、LsyH5PayTest.java:37、LsyOrderUpstreamRejectTest.java:53、LsyQrPayTest.java:43
  • Test: 在上面的 LsyOrderServiceStateTest.java 里加 4 个新用例

Interfaces:

  • Consumes: OrderEventPublisher.changed(String, Long)、CHANNEL_LSY_ORDER(Task 2)
  • Produces: 无新 API;LsyOrderService 构造器 5 参 → 6 参(…, LsyQueryService queryService, OrderEventPublisher publisher))

为什么收口在这两处:applyPayResult 是全仓唯一的「订单状态回写」收口(建单尾、支付回调、轮询确认、关单拒绝、对账都用它),applyRefundResult 是退款结算改订单行(refunded_amount / 状态)的唯一收口 —— 收在这里,9 个调用点一次全覆盖,且天然满足「守卫没命中就什么都不发」。

  • Step 1: 写失败的测试

改 LsyOrderServiceStateTest.java:@Mock 块末尾加一行,setUp 改成 6 参:

    @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;

  • Step 2: 跑它,确认红

Run: cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest=LsyOrderServiceStateTest Expected: 编译失败 actual and formal argument lists differ in length(6 参构造器还不存在)——Java 里这就是「函数签名不存在」的等价红。

  • Step 3: 改实现 + 修另外 4 个构造点

LsyOrderService.java —— import 加 import com.payment.platform.module.payment.stream.OrderEventPublisher;,字段块末尾追加(现有字段块 :40-44,末行是 private final LsyQueryService queryService;):

    /** 订单信号发布(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 个构造点全部带上)

  • Step 4: 跑它,确认绿

Run: cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest='LsyOrderServiceStateTest+LsyBarcodePayTest+LsyH5PayTest+LsyOrderUpstreamRejectTest+LsyQrPayTest' Expected: Failures: 0, Errors: 0(只跑这 5 个类;LsyRefundServiceTest 此刻还是 5 参构造、不受影响)

  • Step 5: 变异验证
变异 期望变红
删掉发布点 A 那一行 applyPayResult_affectedRowIsOne_emitsLsyOrderSignal
把发布点 A 挪到 int rows = 之前(无论命中与否都发) applyPayResult_affectedRowIsZero_emitsNothing
发布点 B 的通道名写成 CHANNEL_HAR_ORDER applyRefundResult_settledRow_emitsLsyOrderSignal
order.getTenantId() 换成 null 上面两条 emit 用例(租户丢了)
  • [ ] Step 6: 提交

    git add java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOrderService.java \
        java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyOrderServiceStateTest.java \
        java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyBarcodePayTest.java \
        java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyH5PayTest.java \
        java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyOrderUpstreamRejectTest.java \
        java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyQrPayTest.java
    git commit -m "feat(订单实时): lsy 订单状态两个收口点发布信号(6 参构造波及 5 个测试类)"
    

Task 5: lsy 订单表其余写点(建单 insert ×4 / 关单撤销 ×3)+ 审计回归

Files:

  • Modify: leshouying/controller/LeshouyingCashierController.java(字段 + insert + 收银台撤销)
  • Modify: leshouying/controller/LeshouyingCheckoutController.java(字段 + insert)
  • Modify: leshouying/controller/open/LeshouyingOpenController.java(字段 + 3 处 insert + 关单)
  • Modify: leshouying/scheduler/LsyOrderPollScheduler.java(字段 + 自动撤销)
  • Modify: leshouying/scheduler/LsyReconcileScheduler.java(字段 + 关单 + 过期)
  • Modify(仅构造点):LeshouyingCashierControllerTest.java:104、LeshouyingCheckoutControllerTest.java:74、LeshouyingOpenControllerTest.java:67/:319/:367、LeshouyingOpenApiErrorContractTest.java:93、LsySchedulerTest.java(14 处:new LsyOrderPollScheduler( ×7 = :105/:119/:133/:162/:194/:221/:233,new LsyReconcileScheduler( ×7 = :247/:263/:296/:347/:374/:403/:520)

Interfaces:

  • Consumes: OrderEventPublisher.changed(String, Long)、CHANNEL_LSY_ORDER
  • Produces: 全部写 lsy_order 的腿都带信号;五个类各有 1 个新构造器尾参 OrderEventPublisher publisher

发布点清单(逐处插入,全部用 order.getTenantId() + CHANNEL_LSY_ORDER):

# 文件 落点
1 LeshouyingOpenController 3 处 orderMapper.insert(order);(:89 / :116 / :141,grep -n "orderMapper.insert(order);" 应恰好 3 处)之后各一行
2 LeshouyingOpenController orderClose:order.setStatus(target); 与 data.put("status", target); 之间
3 LeshouyingCashierController orderMapper.insert(order);(:180)之后
4 LeshouyingCashierController 收银台撤销:order.setStatus(target); 与 return Result.ok(toVo(order, "已撤销")); 之间
5 LeshouyingCheckoutController orderMapper.insert(order);(:208,在 SecurityContext 切换块内)之后
6 LsyOrderPollScheduler 自动撤销分支:order.setStatus(target); 与 callbackService.notifyOrder(order); 之间(:195 附近,16 空格缩进那处)
7 LsyReconcileScheduler applyCloseResult:order.setStatus(target); 与 callbackService.notifyOrder(order); 之间(:499 附近,8 空格缩进)
8 LsyReconcileScheduler markExpired:order.setStatus("EXPIRED"); 与 callbackService.notifyOrder(order); 之间(:562 附近)

插入的这一行(每个点都一样,只有空格缩进跟着上下文):

        publisher.changed(OrderEventPublisher.CHANNEL_LSY_ORDER, order.getTenantId());

不发布的三处(有意为之,审计时不是遗漏):LsyOrderService.stageCodeUrl / markNextQuery(游标类 updateById,订单列表页看不到)、LsyCallbackService.recordOrderNotifyResult(notify_status 列 2026-10-05 已从订单页下线,order/index.vue:145/:272 有「别顺手补回来」的注释)。

  • Step 1: 写失败的测试

在每个既有测试类里加用例,夹具照抄括号里点名的既有用例,只加发布断言:

  • LeshouyingOpenControllerTest(夹具抄 barcodePay_mapsDtoToEntityAndReturnsResult :87):

    @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。

  • Step 2: 跑它,确认红

Run: cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest='LeshouyingOpenControllerTest+LeshouyingCashierControllerTest+LeshouyingCheckoutControllerTest+LsySchedulerTest+LeshouyingOpenApiErrorContractTest' Expected: 编译失败(构造器 5 参/4 参不匹配)—— 改完构造点后重跑,新用例因 Wanted but not invoked 变红。

  • Step 3: 改实现(8 个发布点 + 5 个字段)

5 个类的字段都加在各自字段块末尾:

  • LeshouyingOpenController:字段块末尾是 private final HttpServletRequest request;(:67)
  • LeshouyingCashierController:private final LsyOrderMapper orderMapper;(:161)
  • LeshouyingCheckoutController:private final LsyOrderMapper orderMapper;(:144)
  • LsyOrderPollScheduler:private final LeshouyingProperties props;(:116)
  • LsyReconcileScheduler:private final LeshouyingProperties props;(:127)

字段体(照抄 Task 4 的注释):

    /** 订单信号发布(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 已加的发布点紧邻(同一方法、发布行就在写语句之后),要么属于「不发布的三处」清单。出现第四类就停下来问,别自行判断。

  • Step 5: 跑全量,确认绿

Run: cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test Expected: 本次 stdout 汇总行 Failures: 0, Errors: 0(读汇总行)

  • Step 6: 变异验证(每类抽一个)
变异 期望变红
删掉 LeshouyingOpenController 三处 insert 发布中的任意一处 对应的 *_publishesLsyOrderSignal 用例
orderClose 的发布行挪到条件更新之前 orderClose_条件更新未命中_不发信号
markExpired 的发布行删掉 markExpired 那条新断言
  • [ ] Step 7: 提交

    git add java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCashierController.java \
        java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCheckoutController.java \
        java/src/main/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenController.java \
        java/src/main/java/com/payment/platform/module/payment/leshouying/scheduler/LsyOrderPollScheduler.java \
        java/src/main/java/com/payment/platform/module/payment/leshouying/scheduler/LsyReconcileScheduler.java \
        java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCashierControllerTest.java \
        java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCheckoutControllerTest.java \
        java/src/test/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenControllerTest.java \
        java/src/test/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenApiErrorContractTest.java \
        java/src/test/java/com/payment/platform/module/payment/leshouying/scheduler/LsySchedulerTest.java
    git commit -m "feat(订单实时): lsy 建单/关单/自动撤销/过期八处写点发布信号"
    

Task 6: lsy 退款表(lsy_refund)写点

Files:

  • Modify: leshouying/service/LsyRefundService.java(字段块末尾 + 4 处写点)
  • Modify(仅构造点):LsyRefundServiceTest.java:40(7 参 → 8 参)
  • Test: 在 LsyRefundServiceTest.java 里加 5 条断言

Interfaces:

  • Consumes: OrderEventPublisher.changed(String, Long)、CHANNEL_LSY_REFUND
  • Produces: LsyRefundService 构造器 7 参 → 8 参(尾参 OrderEventPublisher publisher)

发布点(4 处,全部 CHANNEL_LSY_REFUND + 实体自己的 getTenantId()):

# 方法 落点(锚点文本)
1 createRefund refundMapper.insert(entity);(:235,全仓唯一 insert 点)之后
2 persistUpstreamIdentity(LsyRefundEntity entity) 方法末 refundMapper.update(patch, w); 之后
3 persistRefundSettle(LsyRefundEntity refund, …) 方法末 refundMapper.update(patch, w); 之后(settle == null 的早退分支不发 —— 那一支什么都没写)
4 applyNotifyResult refund.setUpstreamResultMsg(resultMsg); 之后、pushRefundCallbackIfTerminal(refund); 之前
  • Step 1: 写失败的测试

LsyRefundServiceTest.java:@Mock 块加 @Mock private OrderEventPublisher publisher;,:40 的构造点尾追加 , publisher,然后加断言(夹具照抄括号里的既有用例,只加发布断言):

    // ① 夹具抄 refund_预插入CREATED_且早于上游调用(:233)
    verify(publisher).changed(OrderEventPublisher.CHANNEL_LSY_REFUND, TENANT);

    // ② 夹具抄 refund_上游响应里的字段_补写到预插入的那一行(:313)
    verify(publisher).changed(OrderEventPublisher.CHANNEL_LSY_REFUND, TENANT);

    // ③ 夹具抄 settleFromQuery_退款查询带refund_settle_落库(:765)
    verify(publisher).changed(OrderEventPublisher.CHANNEL_LSY_REFUND, TENANT);

    // ④ 夹具抄 applyNotifyResult_transitionApplied_returnsTrue(:859)
    verify(publisher).changed(OrderEventPublisher.CHANNEL_LSY_REFUND, TENANT);

    // ⑤ 夹具抄 applyNotifyResult_duplicateCallback_returnsFalse(:904)—— 负例
    verifyNoInteractions(publisher);   // 重复回调一个字节都没改

(TENANT 用该类既有的租户常量/夹具值;占位处必须换成类里真实存在的那个名字,别新造。)

  • Step 2: 跑它,确认红

Run: cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest=LsyRefundServiceTest Expected: 编译失败(8 参构造器不存在)→ 补构造点后新断言 Wanted but not invoked 红。

  • Step 3: 改实现

import com.payment.platform.module.payment.stream.OrderEventPublisher;字段块末尾(private final LeshouyingProperties props; :76 之后):

    /** 退款信号发布(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 一类纯游标写。出现第三类就停下来问。

  • Step 5: 跑它确认绿 + 变异验证

Run: cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest=LsyRefundServiceTest Expected: Failures: 0, Errors: 0

变异 期望变红
删掉 applyNotifyResult 的发布行 ④
把发布行挪到 refundMapper.update(patch, w) != 1 判定之前 ⑤(重复回调也发了)
persistRefundSettle 的提前返回(settle == null)也发 ③ 对应的负例(改成断言 never(),见 Step 1 备注)
  • [ ] Step 6: 提交

    git add java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyRefundService.java \
        java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyRefundServiceTest.java
    git commit -m "feat(订单实时): lsy 退款表四处写点发布信号"
    

Task 7: har 订单表(har_order)写点

Files:

  • Modify: har/service/HarOrderService.java(字段块末尾 + 6 处写点)
  • Modify: har/service/HarNotifyService.java(字段块末尾 + 1 处写点)
  • Modify: har/service/HarCallbackService.java(显式构造器 +1 参 + 1 处写点)
  • Modify(仅构造点):HarOrderServiceTest.java:54、HarNotifyServiceTest.java:60
  • Test: 上面两个测试类各加断言

Interfaces:

  • Consumes: OrderEventPublisher.changed(String, Long)、CHANNEL_HAR_ORDER
  • Produces: HarOrderService 3 参 → 4 参;HarNotifyService 4 参 → 5 参;HarCallbackService 5 参 → 6 参(尾参一律 OrderEventPublisher publisher)

发布点(全部 CHANNEL_HAR_ORDER + order.getTenantId()):

# 方法 落点
1 HarOrderService.doRefund :253-258 那段守卫 if (orderMapper.update(patch, guard) == 0) { … throw … } 之后(状态已置 REFUNDING)
2 HarOrderService.rollbackRefundPreempt order.setRefundReason(null);(:317,方法末)之后 —— 这里回滚成 PAID,页面必须看到
3 HarOrderService.create orderMapper.insert(entity);(:417)之后 —— 新行出现
4 HarOrderService.create orderMapper.updateById(entity);(:440)与 return HarOrderVO.of(entity); 之间 —— 补了 pay_url/上游单号,同一次请求里的第二帧(前端 500ms 合并会吞掉其中一帧,见 Task 9)
5 HarOrderService.closeRejectedOrder orderMapper.update(null, w);(:464)之后
6 HarOrderService.syncFromUpstream 成功尾:orderMapper.updateById(order);(:520)与 return HarOrderVO.of(order); 之间
7 HarNotifyService.handle… orderService.reschedulePoll(order); orderMapper.updateById(order);(:152-153)之后

有意不发的一处:syncFromUpstream 的查询失败分支(:495 那条 updateById)只推 next_query_time 游标,订单列表页看不到这个字段 ⇒ 不发(发了就是每轮轮询推一帧,退化成噪音)。

已知测试空缺(不许忽略):HarCallbackService.writeNotifyStatus 是 private、只在异步回调线程里跑(notifyEnterprise → callbackPool → doNotify → 这里),单测要起 mock RestTemplate + 等异步线程。本计划不为它写单测,由 Task 14 的 E2E 覆盖(HAR 详情弹窗的 notify_status 列)。这是本计划里唯一一处「改了但没单测钉住」的实现 —— 评审时按此定位。

  • Step 1: 写失败的测试

HarOrderServiceTest.java:@Mock 块末尾加 @Mock private OrderEventPublisher publisher;,:54 构造点尾追加 , publisher。在既有用例上加断言:

    // 抄 create_persistsLocalRecordBeforeCallingUpstream(:105)
    verify(publisher, atLeastOnce()).changed(OrderEventPublisher.CHANNEL_HAR_ORDER, TENANT);

    // 抄 openRefund_preemptsToRefundingBeforeCallingUpstream(:817)—— 抢占那一笔要给页面看到
    verify(publisher, atLeastOnce()).changed(OrderEventPublisher.CHANNEL_HAR_ORDER, TENANT);

    // 抄 openRefund_preemptMissed_throwsAndDoesNotCallUpstream(:863)—— 负例
    verify(publisher, never()).changed(any(), any());

    // 抄 openRefund_upstreamNonSuccess_rollsBackToPaid(:925)—— 回滚也要发(页面从「退款中」翻回「已支付」)
    verify(publisher, atLeastOnce()).changed(OrderEventPublisher.CHANNEL_HAR_ORDER, TENANT);

    // 抄 create_upstreamNonSuccessCode_throwsAndClosesTheLocalRow(:253)
    verify(publisher, atLeastOnce()).changed(OrderEventPublisher.CHANNEL_HAR_ORDER, TENANT);

    // 抄 sync_paidStatus_mapsAllFieldsAndStopsPolling(:332)
    verify(publisher, atLeastOnce()).changed(OrderEventPublisher.CHANNEL_HAR_ORDER, TENANT);

    // 抄 sync_queryFailure_stillAdvancesNextQueryTime(:416)—— 负例:只推游标,不发帧
    verify(publisher, never()).changed(any(), any());

HarNotifyServiceTest.java:同样加 @Mock、:60 构造点尾追加 , publisher;在 handle_paidStatus_mapsFieldsAndDelegatesPollRescheduling(:192)加 verify(publisher).changed(OrderEventPublisher.CHANNEL_HAR_ORDER, TENANT);,在 handle_tamperedParam_returnsSignFailedAndDoesNotUpdate(:104)加 verify(publisher, never()).changed(any(), any());。

(TENANT 用每个类里既有的租户常量/夹具值,别新造。)

  • Step 2: 跑它,确认红

Run: cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest='HarOrderServiceTest+HarNotifyServiceTest' Expected: 编译失败(构造器不匹配)→ 补构造点后新断言红。

  • Step 3: 改实现

三个类各自 import + 字段块末尾加:

    /** 订单信号发布(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 失败分支的游标写)。出现第三类就停下来问。

  • Step 5: 跑它确认绿 + 全量回归

Run: cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest='HarOrderServiceTest+HarNotifyServiceTest' Expected: Failures: 0, Errors: 0

Run: cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test Expected: 本次 stdout 汇总行 Failures: 0, Errors: 0 —— 后端到此为止,Task 14 之前不再改 Java 代码

  • Step 6: 变异验证
变异 期望变红
删掉 doRefund 的发布行 openRefund_preemptsToRefunding… 的断言
把 syncFromUpstream 失败分支也加上发布 sync_queryFailure_stillAdvancesNextQueryTime 的 never()
删掉 rollbackRefundPreempt 的发布行 openRefund_upstreamNonSuccess_rollsBackToPaid 的断言
  • [ ] Step 7: 提交

    git add java/src/main/java/com/payment/platform/module/payment/har/service/HarOrderService.java \
        java/src/main/java/com/payment/platform/module/payment/har/service/HarNotifyService.java \
        java/src/main/java/com/payment/platform/module/payment/har/service/HarCallbackService.java \
        java/src/test/java/com/payment/platform/module/payment/har/service/HarOrderServiceTest.java \
        java/src/test/java/com/payment/platform/module/payment/har/service/HarNotifyServiceTest.java
    git commit -m "feat(订单实时): har 订单表八处写点发布信号(含回滚与回调状态)"
    

Task 8: 前端 —— orderStream 工厂(纯模块 + node 执行级验证)

Files:

  • Create: frontend/scripts/verify-order-stream.mjs
  • Create: frontend/src/utils/orderStream.ts(纯模块:零 import,依赖全部注入 —— 这样 node 脚本能原样打包跑)
  • 需要 frontend/scripts/ 目录(现在不存在,Write 会自动建)

Interfaces:

  • Consumes: 无
  • Produces(Task 9/11/12/13 用):
    • createOrderStream(deps: OrderStreamDeps): OrderStream
    • OrderStream.subscribe(channel, handler): () => void / .onResync(handler): () => void / .stop(): void
    • OrderStreamDeps = { url: () => string; createEventSource; now?; setTimer?; clearTimer?; backoffBaseMs?; backoffMaxMs?; watchdogMs? }
    • 线上契约:事件名 changed(数据 {"channel":"…"})与 ping;三个通道名 lsy_order / lsy_refund / har_order(与后端 OrderEventPublisher 逐字一致)

量具说明(为什么是 node 脚本):frontend/package.json 里没有 vitest/jest —— 本仓前端零测试框架。所以用仓库现成的 esbuild(node_modules/esbuild 0.25.12,node v22.22.3)把真 TS 打成一个临时 ESM,配假 EventSource / 假时钟在 node 里跑断言。这不是「近似」:跑的是同一份源文件,只是把浏览器 API 换成注入的空壳。

  • Step 1: 写失败的验证脚本

创建 frontend/scripts/verify-order-stream.mjs:

#!/usr/bin/env node
/**
 * 订单实时流(src/utils/orderStream.ts)的执行级验证。
 * 用法:cd frontend && node scripts/verify-order-stream.mjs
 *
 * 断言失败打印 FAIL 并以非 0 退出;每条 PASS 都打名字(读数要看得见)。
 */
import { build } from "esbuild";
import assert from "node:assert/strict";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import fs from "node:fs/promises";
import os from "node:os";

const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, "..");
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "verify-order-stream-"));
const bundle = path.join(tmp, "orderStream.mjs");

await build({
  entryPoints: [path.join(root, "src/utils/orderStream.ts")],
  outfile: bundle,
  bundle: true,
  format: "esm",
  platform: "neutral",
  logLevel: "error"
});

const { createOrderStream } = await import(pathToFileURL(bundle).href);

// ─────────────────────────── 假件 ───────────────────────────

class FakeEventSource {
  constructor(url, at) {
    this.url = url;
    this.at = at;              // 建连时刻(假时钟读数)
    this.closed = false;
    this.listeners = new Map();
  }
  addEventListener(type, fn) {
    if (!this.listeners.has(type)) this.listeners.set(type, []);
    this.listeners.get(type).push(fn);
  }
  close() {
    this.closed = true;
  }
  fire(type, payload = {}) {
    for (const fn of this.listeners.get(type) || []) fn(payload);
  }
}

function fakeClock() {
  let t = 0;
  const jobs = [];
  let nextId = 1;
  return {
    now: () => t,
    setTimer: (fn, ms) => {
      const id = nextId++;
      jobs.push({ id, at: t + ms, fn });
      return id;
    },
    clearTimer: id => {
      const i = jobs.findIndex(j => j.id === id);
      if (i >= 0) jobs.splice(i, 1);
    },
    advance(ms) {
      const end = t + ms;
      for (;;) {
        const due = jobs.filter(j => j.at <= end).sort((a, b) => a.at - b.at)[0];
        if (!due) break;
        jobs.splice(jobs.indexOf(due), 1);
        t = due.at;
        due.fn();
      }
      t = end;
    }
  };
}

function harness() {
  const clock = fakeClock();
  const built = [];
  const state = { urlCalls: 0 };
  const stream = createOrderStream({
    url: () => {
      state.urlCalls += 1;
      return `http://x/api/v1/payment/stream/orders?token=T${state.urlCalls}`;
    },
    createEventSource: url => {
      const es = new FakeEventSource(url, clock.now());
      built.push(es);
      return es;
    },
    now: clock.now,
    setTimer: clock.setTimer,
    clearTimer: clock.clearTimer,
    backoffBaseMs: 1000,
    backoffMaxMs: 30000,
    watchdogMs: 50000
  });
  return { clock, built, state, stream };
}

let failed = 0;
function check(name, fn) {
  try {
    fn();
    console.log("  PASS  " + name);
  } catch (e) {
    failed += 1;
    console.error("  FAIL  " + name + "\n        " + (e && e.message));
  }
}

// ─────────────────────────── 断言 ───────────────────────────

check("subscribe 建一条连接,URL 来自 url()", () => {
  const { built, stream } = harness();
  const off = stream.subscribe("lsy_order", () => {});
  assert.equal(built.length, 1);
  assert.equal(built[0].url, "http://x/api/v1/payment/stream/orders?token=T1");
  off();
});

check("changed 帧只触发该通道的订阅者", () => {
  const { built, stream } = harness();
  let order = 0;
  let refund = 0;
  const off1 = stream.subscribe("lsy_order", () => { order += 1; });
  const off2 = stream.subscribe("lsy_refund", () => { refund += 1; });

  built[0].fire("changed", { data: JSON.stringify({ channel: "lsy_order" }) });
  assert.equal(order, 1);
  assert.equal(refund, 0, "别的通道的帧不许触发本通道");

  built[0].fire("changed", { data: JSON.stringify({ channel: "har_order" }) });
  assert.equal(order, 1);
  assert.equal(refund, 0, "没人订阅的通道:静默丢弃");

  off1();
  off2();
});

check("坏帧不抛异常、不影响后续帧", () => {
  const { built, stream } = harness();
  let n = 0;
  const off = stream.subscribe("lsy_order", () => { n += 1; });

  built[0].fire("changed", { data: "{oops" });
  built[0].fire("changed", {});
  built[0].fire("changed", { data: JSON.stringify({}) });
  built[0].fire("changed", { data: JSON.stringify({ channel: "lsy_order" }) });

  assert.equal(n, 1, "只有最后那条合法帧该生效");
  off();
});

check("首次 open 不补拉;重连后的 open 补拉一次", () => {
  const { clock, built, stream } = harness();
  let resync = 0;
  const offR = stream.onResync(() => { resync += 1; });
  const off = stream.subscribe("lsy_order", () => {});

  built[0].fire("open", {});
  assert.equal(resync, 0, "首次连接时页面自己正在加载,不该再多拉一次");

  built[0].fire("error", {});
  clock.advance(1000);
  assert.equal(built.length, 2);
  built[1].fire("open", {});
  assert.equal(resync, 1, "断线期间漏掉的帧必须靠重连补拉兜回来");
  assert.equal(built[0].closed, true, "旧连接要显式 close(浏览器自带重连与我们的退避不能两条腿跑)");

  off();
  offR();
});

check("退避 1s→2s→4s→…→30s 封顶", () => {
  const { clock, built, stream } = harness();
  const off = stream.subscribe("lsy_order", () => {});

  let expected = 1;
  for (const delay of [1000, 2000, 4000, 8000, 16000, 30000, 30000]) {
    built[built.length - 1].fire("error", {});
    clock.advance(delay - 1);
    assert.equal(built.length, expected, `${delay}ms 还没到就重连了`);
    clock.advance(1);
    assert.equal(built.length, expected + 1, `${delay}ms 到了还没重连`);
    expected += 1;
  }
  off();
});

check("每次重连都重新调 url()(令牌换新)", () => {
  const { clock, built, state, stream } = harness();
  const off = stream.subscribe("lsy_order", () => {});
  built[0].fire("open", {});
  assert.equal(state.urlCalls, 1);

  built[0].fire("error", {});
  clock.advance(1000);
  assert.equal(state.urlCalls, 2, "重连必须重新拼 URL —— 旧令牌可能已经失效");
  assert.notEqual(built[0].url, built[1].url);
  off();
});

check("50s 没有任何帧(ping 也算)→ 弃掉重建", () => {
  const { clock, built, stream } = harness();
  const off = stream.subscribe("lsy_order", () => {});
  built[0].fire("open", {});

  clock.advance(20000);
  built[0].fire("ping", {});          // 服务端 20s 一个 ping:看门狗被喂饱

  clock.advance(49999);
  assert.equal(built[0].closed, false, "ping 之后不到 50s 不该重建");
  clock.advance(1);
  assert.equal(built[0].closed, true, "50s 没有帧 = 半死连接(TCP 还在、对端不推了),必须重建");
  clock.advance(1000);
  assert.equal(built.length, 2);
  off();
});

check("stop() 关连接、清定时器,此后不再重连", () => {
  const { clock, built, stream } = harness();
  const off = stream.subscribe("lsy_order", () => {});
  built[0].fire("open", {});

  stream.stop();

  assert.equal(built[0].closed, true);
  clock.advance(600000);
  assert.equal(built.length, 1, "stop 之后不许再有任何新连接(登出后 token 已清,重连只会被 403 挡回)");
  off();
});

check("最后一个订阅者退订 → 连接关闭", () => {
  const { built, stream } = harness();
  const off1 = stream.subscribe("lsy_order", () => {});
  const off2 = stream.subscribe("lsy_refund", () => {});

  off1();
  assert.equal(built[0].closed, false, "还有订阅者时不许断");
  off2();
  assert.equal(built[0].closed, true);
});

check("三个通道名的线上契约(与后端 OrderEventPublisher 逐字一致)", () => {
  const CHANNELS = ["lsy_order", "lsy_refund", "har_order"];
  const { built, stream } = harness();
  const hits = { lsy_order: 0, lsy_refund: 0, har_order: 0 };
  const offs = CHANNELS.map(c => stream.subscribe(c, () => { hits[c] += 1; }));

  for (const c of CHANNELS) {
    built[0].fire("changed", { data: JSON.stringify({ channel: c }) });
  }

  assert.deepEqual(hits, { lsy_order: 1, lsy_refund: 1, har_order: 1 });
  offs.forEach(off => off());
});

console.log(failed === 0 ? "\n全部通过" : `\n${failed} 条失败`);
if (failed > 0) process.exit(1);
  • Step 2: 跑它,确认红

Run: cd frontend && node scripts/verify-order-stream.mjs Expected: esbuild 报错 Could not resolve "…/src/utils/orderStream.ts"(模块还不存在),退出码非 0。

  • Step 3: 写实现

创建 frontend/src/utils/orderStream.ts:

/**
 * 订单实时信号(SSE)—— 与后端 `/api/v1/payment/stream/orders` 的单条长连接。
 *
 * 三条纪律(每条都有对应断言,见 scripts/verify-order-stream.mjs):
 * 1. **零用户可见状态**:不显示连接状态、不弹提示、断线也不跳登录页。
 * 2. **url() 每次(重)连现取**:令牌会换(滑动过期、重新登录),只在建连时算一次
 *    等于拿旧令牌反复撞墙。
 * 3. **重连成功后补拉一次**(resync):断线期间漏掉的帧靠它兜;首次 open 不补拉
 *    ——那会儿页面自己正在加载。
 *
 * 本模块是**纯的**(零 import、依赖全注入),所以 node 脚本能原样打包它来跑;
 * 应用级单例在 `@/utils/orderStreamInstance`。
 */

export interface EventSourceLike {
  addEventListener(type: string, fn: (event: any) => void): void;
  close(): void;
}

export interface OrderStreamDeps {
  /** 完整 URL(`?token=` 由调用方拼好)。**每次(重)连都会重新调**。 */
  url: () => string;
  createEventSource: (url: string) => EventSourceLike;
  now?: () => number;
  setTimer?: (fn: () => void, ms: number) => number;
  clearTimer?: (id: number) => void;
  /** 退避起点,默认 1000ms。 */
  backoffBaseMs?: number;
  /** 退避上限,默认 30000ms。 */
  backoffMaxMs?: number;
  /** 看门狗阈值,默认 50000ms(服务端 20s 一个 ping:两个没来就重建)。 */
  watchdogMs?: number;
}

export interface OrderStream {
  /** 订阅一个通道;返回退订函数。第一个订阅者触发建连,最后一个退订触发断连。 */
  subscribe(channel: string, handler: () => void): () => void;
  /** 重连成功(非首次)时回调一次,用来补拉。 */
  onResync(handler: () => void): () => void;
  /** 断连且不再重连(登出时调用)。 */
  stop(): void;
  /** 测试用:当前有没有连接。**页面上不许显示**。 */
  isRunning(): boolean;
}

const DEFAULT_BACKOFF_BASE_MS = 1000;
const DEFAULT_BACKOFF_MAX_MS = 30000;
const DEFAULT_WATCHDOG_MS = 50000;

export function createOrderStream(deps: OrderStreamDeps): OrderStream {
  const backoffBase = deps.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS;
  const backoffMax = deps.backoffMaxMs ?? DEFAULT_BACKOFF_MAX_MS;
  const watchdogMs = deps.watchdogMs ?? DEFAULT_WATCHDOG_MS;
  const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms) as unknown as number);
  const clearTimer = deps.clearTimer ?? (id => clearTimeout(id as unknown as ReturnType<typeof setTimeout>));

  const handlers = new Map<string, Set<() => void>>();
  const resyncHandlers = new Set<() => void>();

  let source: EventSourceLike | null = null;
  let reconnectTimer: number | null = null;
  let watchdogTimer: number | null = null;
  let backoff = backoffBase;
  let everConnected = false;

  function clearTimers(): void {
    if (reconnectTimer !== null) {
      clearTimer(reconnectTimer);
      reconnectTimer = null;
    }
    if (watchdogTimer !== null) {
      clearTimer(watchdogTimer);
      watchdogTimer = null;
    }
  }

  function closeSource(): void {
    if (source) {
      try {
        source.close();
      } catch {
        /* 关不上就算了:连接已经死了 */
      }
      source = null;
    }
  }

  function armWatchdog(): void {
    if (watchdogTimer !== null) {
      clearTimer(watchdogTimer);
    }
    // 半死连接(TCP 还在、对端不推了)不会自己报 error,只能靠「多久没听到声音」判
    watchdogTimer = setTimer(dropAndReconnect, watchdogMs);
  }

  function dropAndReconnect(): void {
    clearTimers();
    closeSource();
    scheduleReconnect();
  }

  function scheduleReconnect(): void {
    if (handlers.size === 0) {
      return; // 没人订阅了就别连(登出后就是这个状态)
    }
    const delay = backoff;
    backoff = Math.min(backoff * 2, backoffMax);
    reconnectTimer = setTimer(connect, delay);
  }

  function connect(): void {
    reconnectTimer = null;
    const es = deps.createEventSource(deps.url()); // ← 每次(重)连都现取 URL
    source = es;

    es.addEventListener("open", () => {
      backoff = backoffBase; // 连上了就重置退避,别把上次的惩罚带进下一次断线
      armWatchdog();
      if (everConnected) {
        for (const fn of resyncHandlers) {
          fn();
        }
      }
      everConnected = true;
    });

    es.addEventListener("changed", (event: any) => {
      armWatchdog();
      let channel: unknown;
      try {
        channel = JSON.parse(event?.data ?? "{}")?.channel;
      } catch {
        return; // 坏帧静默丢弃
      }
      if (typeof channel !== "string") {
        return;
      }
      const set = handlers.get(channel);
      if (set) {
        for (const fn of set) {
          fn();
        }
      }
    });

    es.addEventListener("ping", () => {
      armWatchdog();
    });

    es.addEventListener("error", () => {
      // 不区分「网络断」与「被 401 挡回」:都是退避重连,永远不弹窗、不跳登录页
      if (source === es) {
        dropAndReconnect();
      }
    });
  }

  const stream: OrderStream = {
    subscribe(channel, handler) {
      let set = handlers.get(channel);
      if (!set) {
        set = new Set();
        handlers.set(channel, set);
      }
      set.add(handler);
      if (source === null && reconnectTimer === null) {
        connect();
      }
      return () => {
        const current = handlers.get(channel);
        current?.delete(handler);
        if (current && current.size === 0) {
          handlers.delete(channel);
        }
        if (handlers.size === 0) {
          stream.stop();
        }
      };
    },
    onResync(handler) {
      resyncHandlers.add(handler);
      return () => {
        resyncHandlers.delete(handler);
      };
    },
    stop() {
      clearTimers();
      closeSource();
      backoff = backoffBase;
      everConnected = false;
    },
    isRunning() {
      return source !== null;
    }
  };

  return stream;
}
  • Step 4: 跑它,确认绿

Run: cd frontend && node scripts/verify-order-stream.mjs Expected: 10 条 PASS + 全部通过,退出码 0

  • Step 5: 变异验证(两向)
变异 期望变红
connect() 里把 deps.url() 换成建流时缓存的一次性 URL 每次重连都重新调 url()
删掉 es.addEventListener("ping", armWatchdog) 50s 没有任何帧…(20s 的 ping 不再喂狗)
open 里去掉 backoff = backoffBase 退避序列用例(第二轮的间隔会从 2000 起)
去掉 everConnected 判定(首次 open 也 resync) 首次 open 不补拉…
stop() 里不调 closeSource() stop() 关连接…
error 分支改成 close+connect(不退避) 退避序列用例

任一变异没红 ⇒ 停手修断言。

  • [ ] Step 6: 提交

    git add frontend/scripts/verify-order-stream.mjs frontend/src/utils/orderStream.ts
    git commit -m "feat(订单实时): 前端 SSE 单例工厂(退避重连 + 看门狗 + 重连补拉)"
    

Task 9: 前端 —— 应用单例 + useLiveRefetch(挂起/补刷/合并)

Files:

  • Create: frontend/src/utils/orderStreamInstance.ts(应用单例:真 EventSource + 现取令牌 URL)
  • Create: frontend/src/composables/useLiveRefetch.ts
  • Create: frontend/scripts/verify-live-refetch.mjs
  • Modify: frontend/src/composables/index.ts(加导出)

Interfaces:

  • Consumes: createOrderStream / OrderStream(Task 8)、Auth.getAccessToken(既有)
  • Produces(Task 10/11/12/13 用):
    • useLiveRefetch(channels: string[], refetch: () => unknown, options?: { busy?: Ref<boolean>; debounceMs?: number; stream?: OrderStream }): () => void
    • orderStream(): OrderStream(懒建单例)、stopOrderStream(): void(登出停流)

语义(四条,逐条有断言):

  1. 收到本通道帧 → 合并 500ms 后调 refetch()(同一次写入的多帧只拉一次)
  2. busy === true 期间任何信号(含重连补拉)只记 pending、不刷
  3. busy 由 true 翻 false 且期间有 pending → 立刻静默补刷一次(不等下一个信号)
  4. 默认不额外刷 —— busy 翻转但没有 pending 时什么都不做(防「每次关弹窗都白拉一次」)
  5. refetch 抛错/reject → 吞掉(后台刷新失败不许弹窗、不许打断用户)
  6. 退订/卸载时清定时器、退订全部通道(最后一个退订会让 Task 8 的连接自动关掉)
  • Step 1: 写失败的验证脚本

创建 frontend/scripts/verify-live-refetch.mjs:

#!/usr/bin/env node
/**
 * useLiveRefetch(挂起/补刷/合并)+ 应用单例(URL 拼装、登出停流)的执行级验证。
 * 用法:cd frontend && node scripts/verify-live-refetch.mjs
 *
 * 量具:esbuild 把真 TS + 真 vue 打成一个临时 bundle(vue 必须**打进同一份**,
 * 否则脚本里的 ref 与 bundle 里的 watch 分属两个 vue 实例、依赖收集是断的)。
 */
import { build } from "esbuild";
import assert from "node:assert/strict";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import fs from "node:fs/promises";
import os from "node:os";

const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, "..");
const modulePath = path.join(root, "src/composables/useLiveRefetch.ts").replaceAll("\\", "/");

// Auth 链会读 localStorage/sessionStorage:装壳(模块 import 期不读,保险起见先装)
const makeStore = () => {
  const map = new Map();
  return {
    getItem: k => (map.has(k) ? map.get(k) : null),
    setItem: (k, v) => map.set(k, String(v)),
    removeItem: k => map.delete(k),
    clear: () => map.clear()
  };
};
const localStore = makeStore();
globalThis.localStorage = localStore;
globalThis.sessionStorage = makeStore();

// 单例那条腿要用真 `new EventSource(...)`:换成假件,把 URL 抓下来
const eventSources = [];
globalThis.EventSource = class {
  constructor(url) {
    this.url = url;
    this.closed = false;
    this.listeners = new Map();
    eventSources.push(this);
  }
  addEventListener(type, fn) {
    if (!this.listeners.has(type)) this.listeners.set(type, []);
    this.listeners.get(type).push(fn);
  }
  close() {
    this.closed = true;
  }
};

const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "verify-live-refetch-"));
const bundle = path.join(tmp, "bundle.mjs");

await build({
  stdin: {
    contents: `
      export * from ${JSON.stringify(modulePath)};
      export { ref, nextTick } from "vue";
    `,
    resolveDir: root, // 从 frontend/ 解析:vue 与 "@/..." 都从这里走
    sourcefile: "verify-entry.mjs",
    loader: "js"
  },
  outfile: bundle,
  bundle: true,
  format: "esm",
  platform: "neutral",
  target: "node22",
  logLevel: "error",
  alias: { "@": path.join(root, "src") },
  define: {
    "import.meta.env.VITE_APP_BASE_API": JSON.stringify("/api/v1"),
    __VUE_OPTIONS_API__: "false",
    __VUE_PROD_DEVTOOLS__: "false",
    __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: "false"
  }
});

const { useLiveRefetch, orderStream, stopOrderStream, ref, nextTick } = await import(pathToFileURL(bundle).href);

const sleep = ms => new Promise(r => setTimeout(r, ms));

function fakeStream() {
  const subs = new Map();
  const resyncs = new Set();
  let unsubscribed = 0;
  return {
    subscribe(channel, fn) {
      if (!subs.has(channel)) subs.set(channel, new Set());
      subs.get(channel).add(fn);
      return () => {
        subs.get(channel)?.delete(fn);
        unsubscribed += 1;
      };
    },
    onResync(fn) {
      resyncs.add(fn);
      return () => resyncs.delete(fn);
    },
    stop() {},
    isRunning: () => true,
    emit(channel) {
      for (const fn of subs.get(channel) || []) fn();
    },
    fireResync() {
      for (const fn of resyncs) fn();
    },
    get channels() {
      return [...subs.keys()].sort();
    },
    get unsubscribed() {
      return unsubscribed;
    }
  };
}

let failed = 0;
async function check(name, fn) {
  try {
    await fn();
    console.log("  PASS  " + name);
  } catch (e) {
    failed += 1;
    console.error("  FAIL  " + name + "\n        " + (e && e.message));
  }
}

const OPTS = { debounceMs: 5 };

await check("订阅给定的三个通道", async () => {
  const stream = fakeStream();
  let calls = 0;
  const dispose = useLiveRefetch(["lsy_order", "lsy_refund", "har_order"], () => { calls += 1; }, { ...OPTS, stream });

  assert.deepEqual(stream.channels, ["har_order", "lsy_order", "lsy_refund"]);
  dispose();
});

await check("本通道的帧 → 合并窗口后刷一次;别的通道不触发", async () => {
  const stream = fakeStream();
  let calls = 0;
  const dispose = useLiveRefetch(["lsy_order"], () => { calls += 1; }, { ...OPTS, stream });

  stream.emit("lsy_refund");
  await sleep(20);
  assert.equal(calls, 0, "没订阅的通道不许刷");

  stream.emit("lsy_order");
  assert.equal(calls, 0, "合并窗口内不许同步刷(同一次写入会来好几帧)");
  await sleep(20);
  assert.equal(calls, 1);

  dispose();
});

await check("窗口内多帧(含重连补拉)只刷一次", async () => {
  const stream = fakeStream();
  let calls = 0;
  const dispose = useLiveRefetch(["lsy_order"], () => { calls += 1; }, { ...OPTS, stream });

  stream.emit("lsy_order");
  stream.emit("lsy_order");
  stream.fireResync();
  await sleep(20);

  assert.equal(calls, 1, "一次写入的多帧 + 一次重连补拉,合并成一次重拉");
  dispose();
});

await check("busy 期间挂起,回安全态立刻补刷一次", async () => {
  const stream = fakeStream();
  const busy = ref(false);
  let calls = 0;
  const dispose = useLiveRefetch(["lsy_order"], () => { calls += 1; }, { ...OPTS, stream, busy });

  busy.value = true;
  await nextTick();

  stream.emit("lsy_order");
  stream.emit("lsy_order");
  await sleep(20);
  assert.equal(calls, 0, "用户正在勾选/弹窗里操作,列表不许在脚下跳");

  busy.value = false;
  await nextTick();
  assert.equal(calls, 1, "回到安全态立刻补刷(不等下一个信号)");

  await sleep(20);
  assert.equal(calls, 1, "补刷只做一次(pending 要清掉)");
  dispose();
});

await check("busy 翻转但期间没有信号 → 一次都不刷", async () => {
  const stream = fakeStream();
  const busy = ref(false);
  let calls = 0;
  const dispose = useLiveRefetch(["lsy_order"], () => { calls += 1; }, { ...OPTS, stream, busy });

  busy.value = true;
  await nextTick();
  busy.value = false;
  await nextTick();
  await sleep(20);

  assert.equal(calls, 0, "没漏东西就白拉一次 = 每次关弹窗都打一次接口");
  dispose();
});

await check("busy 期间的重连补拉也挂起", async () => {
  const stream = fakeStream();
  const busy = ref(true);
  let calls = 0;
  const dispose = useLiveRefetch(["lsy_order"], () => { calls += 1; }, { ...OPTS, stream, busy });
  await nextTick();

  stream.fireResync();
  await sleep(20);
  assert.equal(calls, 0);

  busy.value = false;
  await nextTick();
  assert.equal(calls, 1);
  dispose();
});

await check("refetch 抛错 / reject 都不冒泡,后续帧仍能刷", async () => {
  const stream = fakeStream();
  let calls = 0;
  const dispose = useLiveRefetch(
    ["lsy_order"],
    () => {
      calls += 1;
      if (calls === 1) throw new Error("同步炸");
      if (calls === 2) return Promise.reject(new Error("异步炸"));
      return Promise.resolve();
    },
    { ...OPTS, stream }
  );

  stream.emit("lsy_order");
  await sleep(20);
  stream.emit("lsy_order");
  await sleep(20);
  stream.emit("lsy_order");
  await sleep(20);

  assert.equal(calls, 3, "三次都该真的调下去(异常被吞掉,不影响下一次)");
  dispose();
});

await check("dispose:退订全部通道、清掉未到期的合并、不再刷", async () => {
  const stream = fakeStream();
  let calls = 0;
  const dispose = useLiveRefetch(["lsy_order", "lsy_refund"], () => { calls += 1; }, { ...OPTS, stream });

  stream.emit("lsy_order");
  dispose();
  assert.equal(stream.unsubscribed, 2, "两个通道都要退订");

  await sleep(20);
  assert.equal(calls, 0, "dispose 时未到期的合并必须被清掉(页面已经卸载了)");

  stream.emit("lsy_order");
  await sleep(20);
  assert.equal(calls, 0);
});

await check("单例:URL 现取令牌拼装 + 登出停流", async () => {
  // Storage.get 会 JSON.parse,所以按 Storage.set 的写法存(带引号的 JSON 串)
  localStore.setItem("remember_me", "true");
  localStore.setItem("access_token", JSON.stringify("tok-123"));

  assert.equal(orderStream(), orderStream(), "必须是同一个实例");

  const off = orderStream().subscribe("lsy_order", () => {});
  assert.equal(eventSources.length, 1);
  assert.equal(eventSources[0].url, "/api/v1/payment/stream/orders?token=tok-123");

  stopOrderStream();
  assert.equal(eventSources[0].closed, true, "登出停流必须真的关连接(token 已清,重连只会被 403 挡回)");

  off();
});

console.log(failed === 0 ? "\n全部通过" : `\n${failed} 条失败`);
if (failed > 0) process.exit(1);
  • Step 2: 跑它,确认红

Run: cd frontend && node scripts/verify-live-refetch.mjs Expected: esbuild 报错 Could not resolve "…/src/composables/useLiveRefetch.ts"(模块还不存在),退出码非 0。

  • Step 3: 写实现

创建 frontend/src/utils/orderStreamInstance.ts:

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";
  • Step 4: 跑它,确认绿

Run: cd frontend && node scripts/verify-live-refetch.mjs Expected: 9 条 PASS + 全部通过,退出码 0

  • Step 5: 变异验证
变异 期望变红
schedule 里把 busy?.value 判断删掉(不挂起) busy 期间挂起…
watch 回调里改成 if (!isBusy) run()(不看 pending) busy 翻转但期间没有信号…
watch 回调里去掉 pending = false busy 期间挂起… 的「补刷只做一次」
去掉 .catch(() => {}) refetch 抛错 / reject…(node 会因未处理的 rejection 崩)
dispose 里不清 timer dispose:… 的「未到期的合并必须被清掉」
orderStreamInstance 的 URL 去掉 encodeURIComponent 也能过 —— 注意:这条变异不会红(令牌是 URL-safe 的),它防的是未来别的字符,保留即可(如实记下:这条没有断言兜着) —
  • [ ] Step 6: 提交

    git add frontend/src/utils/orderStreamInstance.ts frontend/src/composables/useLiveRefetch.ts \
        frontend/src/composables/index.ts frontend/scripts/verify-live-refetch.mjs
    git commit -m "feat(订单实时): useLiveRefetch —— busy 挂起 / 静默补刷 / 500ms 合并"
    

Task 10: 前端管线 —— 静默开关 + API 透传 + 登出停流 + 清死文件

Files:

  • Modify: frontend/src/utils/request.ts(模块扩展 + 5 处 ElMessage.error 加闸 + 成功 toast 加闸)
  • Modify: frontend/src/api/module_payment/collection.ts(getLsyOrderPage / getLsyRefundPage 各加 silent 形参)
  • Modify: frontend/src/api/module_payment/har.ts(orderList / orderDetail 各加 silent 形参)
  • Modify: frontend/src/store/modules/user.store.ts(resetAllState 里停流)
  • Delete: frontend/src/utils/websocket.ts(零引用死文件)

Interfaces:

  • Consumes: stopOrderStream()(Task 9)
  • Produces: axios 配置新增 silent?: boolean;getLsyOrderPage(params, silent=false) / getLsyRefundPage(params, silent=false) / HarAPI.orderList(params, silent=false) / HarAPI.orderDetail(id, silent=false)

为什么要有 silent:后台自动刷新失败时弹 toast 是用户没做过任何操作的报错 —— 违反「零感知」。但 401/TOKEN_EXPIRED 的处理一个字都不改(登录失效必须跳登录页)。

这一步没有 node 脚本:request.ts 的依赖链是 axios + qs + @/utils/authRedirect → @/router + pinia store,在 node 里打包它等于把整个应用拉起来。验证手段是「ts:check 同机差分 + grep 逐条点数 + Task 14 的浏览器级断言(弹窗打开时不出现 toast)」,如实记下:拦截器的静默行为没有单测。

  • [ ] Step 1: 先量基线(这步不能省)

    cd frontend && pnpm ts:check 2>&1 | tee /tmp/ts-before.txt; echo "基线 exit=$?"
    

记下输出(基线 exit 2 是正常的,本仓有历史遗留类型错)。本轮所有前端任务都用「与这份输出 diff」判有没有新增错误。

  • Step 2: 改 request.ts

import 之后(:10 import { redirectToLogin } … 那行下面)加模块扩展:

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;
  • :94 if (!silent) { ElMessage.error(errorMessage); }
  • :141-153 四个分支:if (!silent) { ElMessage.error(data.msg || "…"); }(每个分支只包 ElMessage,return Promise.reject(...) 照旧)
  • :133-140 的 401 / TOKEN_EXPIRED 分支一个字都不许改(那一支必须跳登录)
  • blob 分支(:101-119)不动(silent 路径全是 JSON)

  • [ ] Step 3: 改两个 API 文件

collection.ts:

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,
    });
  },
  • Step 4: 登出停流

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"
    

Task 11: 支付订单页(lsy)接线 —— 列表实时刷 + 抽屉联动

Files:

  • Modify: frontend/src/api/module_payment/collection.ts(getLsyOrderDetail / getLsyOrderRefunds 各加 silent 形参 —— Task 10 只改了两条列表腿,这两条是抽屉联动需要的)
  • Modify: frontend/src/views/module_payment/collection/order/index.vue

Interfaces:

  • Consumes: useLiveRefetch(channels, refetch, options)(Task 9)、getLsyOrderPage(params, silent=false)(Task 10)、getLsyOrderDetail(outTradeNo, silent=false) / getLsyOrderRefunds(outTradeNo, silent=false)(本任务 Step 1 补)
  • Produces: 无(末梢接线)

为什么要听两个通道:本页的列里有 refunded_amount / 退款中标记 / status(PARTIAL_REFUNDED、REFUNDED)—— 那些字段只被退款写库改动,而退款写库发的是 lsy_refund。只听 lsy_order 的话,退完款页面停在旧金额上。

下面所有行号都是 2026-10-05 在本工作区量出来的(grep -n 读数);实施时按文本锚点找,行号变了以文本为准。

  • Step 1: 补两条详情腿的 silent 形参

collection.ts 里(:42 与 :60):

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 早已无人调用,不动。)

  • Step 2: 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;
    }
  }
}
  • Step 3: 模板里的裸函数引用必须改成箭头函数(这一步漏了就会静默变错)

: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 "↑ 空 = 没有裸引用"
  • Step 4: 插入实时联动块

插入位置:紧跟在 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";
  • Step 5: 退款确认框期间置位 confirmingRefund

handleRefund 里那段 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 双通道、操作中挂起、抽屉静默联动"
    

Task 12: 退款记录页(lsy)接线

Files:

  • Modify: frontend/src/views/module_payment/collection/refund/index.vue

Interfaces:

  • Consumes: useLiveRefetch(Task 9)、getLsyRefundPage(params, silent=false)(Task 10)
  • Produces: 无

页面现状(2026-10-05 实测,决定这一页怎么接):整个 <script setup> 只有 query / list / total / loading 四个状态(:104-107),没有勾选、没有弹窗、没有行内操作按钮(模板里只有查询/重置两个按钮 + 一个只读表格 + 分页)。⇒ 不传 busy:这一页没有任何「操作中」的态,任何一帧都直接刷。

  • Step 1: loadData 加静默参数

:116-126:

async function loadData(silent = false) {
  if (!silent) {
    loading.value = true;
  }
  try {
    const res: any = await getLsyRefundPage(query, silent);
    // 两层 `.data.data`(响应拦截器不解包);分页字段名是 `list`(原有注释保留)
    list.value = res.data.data?.list || [];
    total.value = res.data.data?.total || 0;
  } finally {
    if (!silent) {
      loading.value = false;
    }
  }
}
  • Step 2: 模板 @change="loadData"(:76)→ @change="() => loadData()"

同 Task 11 Step 3 的理由(el-pagination 的 change 把页码当第一个实参)。

  • Step 3: onMounted(loadData)(:139)→ onMounted(() => loadData())

Vue 调用生命周期钩子时不传参,所以 onMounted(loadData) 今天是对的;但下一个人看不出这一点,而这个函数现在有了可选首参 —— 写成箭头函数把它变成显式的。

  • Step 4: 插入实时联动

插入位置:紧接在 onMounted(() => loadData()); 之前(:139 处),此时 query / loadData 都已定义。

// 退款记录页:只听 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 单通道静默重拉"
    

Task 13: HAR 订单页接线

Files:

  • Modify: frontend/src/views/module_payment/har/order/index.vue

Interfaces:

  • Consumes: useLiveRefetch(Task 9)、HarAPI.orderList(params, silent=false) / HarAPI.orderDetail(id, silent=false)(Task 10)
  • Produces: 无

这一页是三条接线里最危险的一条:分页事件的处理器是裸函数名(:211 @size-change="load"、:212 @current-change="load"),而 el-pagination 这两条事件都会把新页码/新页长当作第一个实参传进来。不加箭头函数的话 silent 会永远拿到一个数字。

  • Step 1: load 加静默参数

:566-586:

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() 读到的是新页码 ✓。

  • Step 3: 插入实时联动块

插入位置:紧接在 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";
  • Step 4: 退款确认框期间置位 confirmingRefund

handleRefund 里 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 里。

  • Step 6: 三页通道名审计(一次性,防拼错)

通道名是前后端之间唯一的字符串契约(后端 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 单通道、分页裸引用改箭头函数、详情联动"
    

Task 14: 端到端验收 + 构建

Files:

  • 不新增/修改源码(这一任务只产出读数;发现缺陷就回到对应任务修,别在这里打补丁)

Interfaces:

  • Consumes: 前 13 个任务的全部产物
  • Produces: jar / dist 指纹与验收读数(修完就报,不 push)

前置:库隧道 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 保证抓得到一帧)。

  • Step 5: 一帧 write→push 的闭合验证(同一个 token,两个终端)

终端 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 的浏览器断言把它变成必红用例)。

  • Step 6: 浏览器级断言(Playwright MCP,登 q)

逐条记录实际看到的东西(截图/原始响应),不要只记「通过」:

  1. 一条连接:打开支付订单页 → Network 里 stream/orders 恰好 1 条且长期 pending;切到退款记录页、HAR 订单页 → 仍是那 1 条(模块级单例;页面切换不新增连接)。
  2. 新增订单自动出现:重跑 Step 5 的收银台下单 → 支付订单页 1 秒内多出一行,没有点任何东西。
  3. 租户隔离 + 正控(spec 验收 #2):登 q(tenant 2)打开支付订单页,记下 total 与 Network 里 /order/page 的请求数 → 让 tenant 27 的一笔单变化 → q 的页面纹丝不动(total 不变、不出现新的 /order/page 请求)。正控必须先量:用 q 自己的令牌下一单 → q 的页面必须变 —— 「不变」只有在「会变」被量到之后才算证据。
    • 本机没有 tenant 27 的凭据(已知其登录手机号 15616004668,密码未持有)⇒ 这一步要么向用户要一次 27 的账号,要么如实记「E2E 隔离未跑,隔离只有 Task 1 的单元级变异覆盖」。不许因为「单元用例绿了」就在验收单上写隔离通过 —— 那是两种量具。
  4. 零感知:整个过程 .el-message(toast)数量为 0;页面没有任何新元素/角标/状态点(与改前截图对比)。
  5. 操作中挂起 + 回安全态补刷:勾选一行 → 再下一单 → 列表不动(total 与首行不变)→ 取消勾选 → 无需操作,1 秒内列表自己补上。
  6. 抽屉联动(偏差 ① 的验收):打开某单详情 → 对同一单触发退款(或等回调)→ 抽屉里的状态/已退金额跟着变,且不闪空白。
  7. 连掉再连回:停后端 → 观察页面(不报错、不跳登录、无 toast)→ 起后端 → 不做任何操作,列表在重连补拉后自动补齐停服期间的变更。
  8. 静置 2 分钟:连接仍 pending,Network 里 stream/orders 仍只有 1 条(本机经 Vite 代理;生产 nginx 的缓冲只能部署后验,见 Review Focus ⑤)。
  • Step 7: 交付读数汇总(写给人看,不 push)

把 Step 1-3 的三组指纹(测试汇总行 / jar 名+字节+mtime / dist 指纹)与 Step 4-6 的实测结果一起贴出来。提交已经按任务做完了;是否 push、是否部署(重打 jar + dist 上线)由用户拍板 —— 参考本仓的一条既有教训:没提交的交付等于没交付,而这些提交目前仍在本地 java 分支上。

cd /d/project2/payment-platform && git log --oneline -14

完成判据(全部满足才算实现完成)

  • mvn test 全绿(本次 stdout 汇总行)
  • pnpm build exit 0,且三个通道名出现在 dist/assets
  • curl 三连:无 token 403 / 坏 token 401 / 好 token 收得到 event: ping 与 data: {"channel":"lsy_order"}
  • 三页在真实写入后无需操作自动更新;.el-message 数为 0
  • 勾选期间不刷、取消勾选后补刷一次
  • 断线自动重连 + 重连后补拉,全程无用户可见状态
  • 全部改动已提交(git log --oneline 可见每个任务各自的提交)