Răsfoiți Sursa

feat(订单实时): lsy 建单/关单/自动撤销/过期八处写点发布信号

- 5 个类各加构造器尾参 OrderEventPublisher;八处发布点统一为
  publisher.changed(CHANNEL_LSY_ORDER, order.getTenantId()):开放 API 三个建单 + 关单、
  收银台建单 + 撤销、收款链接建单、轮询自动撤销、对账关单、对账绝对过期。
- 只在真的改了行之后发:三处守卫未命中分支各加 verifyNoInteractions(publisher)。
- 审计(grep orderMapper.insert/update/updateById)逐条归类:除八处外只剩清单内豁免的
  stageCodeUrl 与 markNextQuery(含收银台孪生、轮询游标)、recordOrderNotifyResult。
- LeshouyingChannelSwitchWiringTest 切片补 OrderEventPublisher mock:它按类字面量注册两个
  调度器,new X( 口径的构造点普查扫不到它(全量跑红才发现,UnsatisfiedDependencyException)。
- 读数:实现行缺席时 98 用例 8 红(恰是八条发布断言);补 qrcode/h5 两条 insert 用例;
  M1 删 qrcode 发布行到 qrcodeCreate 红、M2 发布行挪到条件更新之前到 orderClose 未命中红、
  M3 删 markExpired 发布行到 expireStale_stillPaying 红;全量 1076 用例 85 类 0 失败。
alphaH 1 săptămână în urmă
părinte
comite
f5a36e9aae

+ 5 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCashierController.java

@@ -20,6 +20,7 @@ import com.payment.platform.module.payment.leshouying.service.LsyMerchantService
 import com.payment.platform.module.payment.leshouying.service.LsyOrderService;
 import com.payment.platform.module.payment.leshouying.service.LsyQueryService;
 import com.payment.platform.module.payment.leshouying.util.LsyLogSanitizer;
+import com.payment.platform.module.payment.stream.OrderEventPublisher;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -159,6 +160,8 @@ public class LeshouyingCashierController {
     private final LsyCancelService cancelService;
     private final LsyMerchantService merchantService;
     private final LsyOrderMapper orderMapper;
+    /** 订单信号发布(SSE):只在**真的改了行**之后发。 */
+    private final OrderEventPublisher publisher;
 
     // ──────────────────────────── 1. 出二维码 ────────────────────────────
 
@@ -178,6 +181,7 @@ public class LeshouyingCashierController {
 
         LsyOrderEntity order = newOrder(tenantId, merchant, dto);
         orderMapper.insert(order);
+        publisher.changed(OrderEventPublisher.CHANNEL_LSY_ORDER, order.getTenantId());
 
         try {
             orderService.createQrOrder(order, null);
@@ -271,6 +275,7 @@ public class LeshouyingCashierController {
             return Result.of(500, "撤销未生效:这一单的状态刚刚变了(可能顾客已经付款),请以页面刷新后的状态为准");
         }
         order.setStatus(target);
+        publisher.changed(OrderEventPublisher.CHANNEL_LSY_ORDER, order.getTenantId());
         return Result.ok(toVo(order, "已撤销"));
     }
 

+ 4 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCheckoutController.java

@@ -16,6 +16,7 @@ import com.payment.platform.module.payment.leshouying.service.LsyCheckoutLinkSer
 import com.payment.platform.module.payment.leshouying.service.LsyMerchantService;
 import com.payment.platform.module.payment.leshouying.service.LsyOrderService;
 import com.payment.platform.module.payment.leshouying.util.LsyAmountFloor;
+import com.payment.platform.module.payment.stream.OrderEventPublisher;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -142,6 +143,8 @@ public class LeshouyingCheckoutController {
     private final LsyMerchantService merchantService;
     private final LsyOrderService orderService;
     private final LsyOrderMapper orderMapper;
+    /** 订单信号发布(SSE):只在**真的改了行**之后发。 */
+    private final OrderEventPublisher publisher;
 
     // ──────────────────────────── 1. 开屏:显示商户名 ────────────────────────────
 
@@ -206,6 +209,7 @@ public class LeshouyingCheckoutController {
 
             LsyOrderEntity order = newOrder(link, merchant, amount);
             orderMapper.insert(order);
+            publisher.changed(OrderEventPublisher.CHANNEL_LSY_ORDER, order.getTenantId());
 
             try {
                 orderService.createQrOrder(order, null);

+ 8 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenController.java

@@ -18,6 +18,7 @@ import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
 import com.payment.platform.module.payment.leshouying.service.*;
 import com.payment.platform.module.payment.leshouying.util.LsyLogSanitizer;
 import com.payment.platform.module.payment.leshouying.util.LsyStrings;
+import com.payment.platform.module.payment.stream.OrderEventPublisher;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
@@ -65,6 +66,9 @@ public class LeshouyingOpenController {
     private final LsyOrderMapper orderMapper;
     /** 请求作用域代理 —— 与既有 OpenapiController 同一写法,从 attribute 取认证上下文 */
     private final HttpServletRequest request;
+    /** 订单信号发布(SSE):只在**真的改了行**之后发 —— 守卫没命中的分支一行都没变,
+     *  发了就是让订单页白拉一次。 */
+    private final OrderEventPublisher publisher;
 
     @PostMapping("/barcode/pay")
     public LsyPayResultVO barcodePay(@Valid @RequestBody LsyBarcodePayDTO dto) {
@@ -87,6 +91,7 @@ public class LeshouyingOpenController {
         order.setShopNo(merchant.getShopNo());
         // ★ 落库必须在 mch_no/shopNo 装配之后:V1.8 的 pay_lsy_order.mch_no 是 NOT NULL 且无默认值
         orderMapper.insert(order);
+        publisher.changed(OrderEventPublisher.CHANNEL_LSY_ORDER, order.getTenantId());
 
         // 注意:auth_code 不写进订单实体(不落库),只透传给上游
         return toVo(orderService.createBarcodeOrder(
@@ -114,6 +119,7 @@ public class LeshouyingOpenController {
         order.setShopNo(merchant.getShopNo());
         // ★ 落库必须在 mch_no/shopNo 装配之后:V1.8 的 pay_lsy_order.mch_no 是 NOT NULL 且无默认值
         orderMapper.insert(order);
+        publisher.changed(OrderEventPublisher.CHANNEL_LSY_ORDER, order.getTenantId());
 
         return toVo(orderService.createQrOrder(order, dto.getTimeExpire()), null);
     }
@@ -139,6 +145,7 @@ public class LeshouyingOpenController {
         order.setShopNo(merchant.getShopNo());
         // ★ 落库必须在 mch_no/shopNo 装配之后:V1.8 的 pay_lsy_order.mch_no 是 NOT NULL 且无默认值
         orderMapper.insert(order);
+        publisher.changed(OrderEventPublisher.CHANNEL_LSY_ORDER, order.getTenantId());
 
         return toVo(orderService.createH5Order(order, dto.getPayType()), null);
     }
@@ -258,6 +265,7 @@ public class LeshouyingOpenController {
             return Result.fail(500, "关单结果未生效:订单状态已变更(可能已支付),请重新查询订单状态");
         }
         order.setStatus(target);
+        publisher.changed(OrderEventPublisher.CHANNEL_LSY_ORDER, order.getTenantId());
         data.put("status", target);
         return Result.ok(data);
     }

+ 4 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/scheduler/LsyOrderPollScheduler.java

@@ -9,6 +9,7 @@ import com.payment.platform.module.payment.leshouying.service.LsyCallbackService
 import com.payment.platform.module.payment.leshouying.service.LsyCancelService;
 import com.payment.platform.module.payment.leshouying.service.LsyOrderService;
 import com.payment.platform.module.payment.leshouying.service.LsyQueryService;
+import com.payment.platform.module.payment.stream.OrderEventPublisher;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -114,6 +115,8 @@ public class LsyOrderPollScheduler {
     private final LsyCallbackService callbackService;
     private final LsyOrderService orderService;
     private final LeshouyingProperties props;
+    /** 订单信号发布(SSE):只在**真的改了行**之后发。 */
+    private final OrderEventPublisher publisher;
 
     @Scheduled(fixedDelay = 30 * 1000)
     public void pollBarcodeOrders() {
@@ -193,6 +196,7 @@ public class LsyOrderPollScheduler {
                     return;
                 }
                 order.setStatus(target);
+                publisher.changed(OrderEventPublisher.CHANNEL_LSY_ORDER, order.getTenantId());
                 callbackService.notifyOrder(order);
             }
             return;

+ 5 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/scheduler/LsyReconcileScheduler.java

@@ -15,6 +15,7 @@ import com.payment.platform.module.payment.leshouying.service.LsyCancelService;
 import com.payment.platform.module.payment.leshouying.service.LsyOrderService;
 import com.payment.platform.module.payment.leshouying.service.LsyQueryService;
 import com.payment.platform.module.payment.leshouying.service.LsyRefundService;
+import com.payment.platform.module.payment.stream.OrderEventPublisher;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -125,6 +126,8 @@ public class LsyReconcileScheduler {
     private final LsyOrderService orderService;
     private final LsyRefundService refundService;
     private final LeshouyingProperties props;
+    /** 订单信号发布(SSE):只在**真的改了行**之后发。 */
+    private final OrderEventPublisher publisher;
 
     /**
      * 「上游说它那儿没有这笔单/这笔退款」的原文片段。
@@ -497,6 +500,7 @@ public class LsyReconcileScheduler {
             return;
         }
         order.setStatus(target);
+        publisher.changed(OrderEventPublisher.CHANNEL_LSY_ORDER, order.getTenantId());
         callbackService.notifyOrder(order);
     }
 
@@ -560,6 +564,7 @@ public class LsyReconcileScheduler {
             return;
         }
         order.setStatus("EXPIRED");
+        publisher.changed(OrderEventPublisher.CHANNEL_LSY_ORDER, order.getTenantId());
         callbackService.notifyOrder(order);
     }
 }

+ 5 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/config/LeshouyingChannelSwitchWiringTest.java

@@ -9,6 +9,7 @@ import com.payment.platform.module.payment.leshouying.service.LsyCancelService;
 import com.payment.platform.module.payment.leshouying.service.LsyOrderService;
 import com.payment.platform.module.payment.leshouying.service.LsyQueryService;
 import com.payment.platform.module.payment.leshouying.service.LsyRefundService;
+import com.payment.platform.module.payment.stream.OrderEventPublisher;
 import org.junit.jupiter.api.Test;
 import org.mockito.Mockito;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -96,6 +97,10 @@ class LeshouyingChannelSwitchWiringTest {
                 .withBean(LsyCallbackService.class, () -> Mockito.mock(LsyCallbackService.class))
                 .withBean(LsyOrderService.class, () -> Mockito.mock(LsyOrderService.class))
                 .withBean(LsyRefundService.class, () -> Mockito.mock(LsyRefundService.class))
+                // 订单信号发布(SSE)—— 两个调度器 2026-10-05 起各多一个构造依赖,
+                // 本切片里同样用 mock 补齐(别改成让生产类可选:生产装配在
+                // PaymentApplicationContextTest 里已验,缺的只是本切片的登记)。
+                .withBean(OrderEventPublisher.class, () -> Mockito.mock(OrderEventPublisher.class))
                 .withUserConfiguration(LsyOrderPollScheduler.class, LsyReconcileScheduler.class);
     }
 

+ 48 - 1
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCashierControllerTest.java

@@ -18,6 +18,7 @@ import com.payment.platform.module.payment.leshouying.service.LsyMerchantService
 import com.payment.platform.module.payment.leshouying.service.LsyOrderService;
 import com.payment.platform.module.payment.leshouying.service.LsyQueryService;
 import com.payment.platform.module.payment.leshouying.util.LsyBizNoPattern;
+import com.payment.platform.module.payment.stream.OrderEventPublisher;
 import org.apache.ibatis.builder.MapperBuilderAssistant;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
@@ -96,13 +97,14 @@ class LeshouyingCashierControllerTest {
     @Mock private LsyCancelService cancelService;
     @Mock private LsyMerchantService merchantService;
     @Mock private LsyOrderMapper orderMapper;
+    @Mock private OrderEventPublisher publisher;
 
     private MockMvc mockMvc;
 
     @BeforeEach
     void setUp() {
         LeshouyingCashierController controller = new LeshouyingCashierController(
-                orderService, queryService, cancelService, merchantService, orderMapper);
+                orderService, queryService, cancelService, merchantService, orderMapper, publisher);
         mockMvc = MockMvcBuilders.standaloneSetup(controller)
                 .setControllerAdvice(new GlobalExceptionHandler())
                 .setMessageConverters(new MappingJackson2HttpMessageConverter(
@@ -276,6 +278,21 @@ class LeshouyingCashierControllerTest {
         assertNull(o.getNotifyUrl(), "有意不设 —— 见本方法 javadoc");
     }
 
+    @Test
+    void createQr_publishesLsyOrderSignal() throws Exception {
+        // 夹具与 create_assemblesTheOrder_rowShapeAndServerDecidedFields 逐行一致
+        login(TENANT, false);
+        when(merchantService.requireByTenant(TENANT)).thenReturn(merchantRow());
+        when(orderService.createQrOrder(any(), any())).thenReturn(null);
+
+        mockMvc.perform(post(PREFIX + "/qrcode")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"amount\":" + AMOUNT + ",\"remark\":\"3 号桌\"}"))
+                .andExpect(jsonPath("$.code").value(0));
+
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_LSY_ORDER, TENANT);
+    }
+
     /**
      * 单号由服务端生成:操作员不输入任何单号。
      *
@@ -495,6 +512,36 @@ class LeshouyingCashierControllerTest {
                 .andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("未生效")));
     }
 
+    @Test
+    void close_appliesGuardedUpdate_publishesLsyOrderSignal() throws Exception {
+        // 夹具与 close_appliesGuardedUpdate_andReportsTheNewStatus 逐行一致
+        login(TENANT, false);
+        LsyOrderEntity row = orderRow("PAYING");
+        when(orderMapper.selectOne(any())).thenReturn(row);
+        when(cancelService.closeOrder(row)).thenReturn("CANCELED");
+        when(orderMapper.update(any(), any())).thenReturn(1);
+
+        mockMvc.perform(post(PREFIX + "/" + OTN + "/close"))
+                .andExpect(jsonPath("$.code").value(0));
+
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_LSY_ORDER, TENANT);
+    }
+
+    @Test
+    void close_whenGuardMisses_publishesNothing() throws Exception {
+        // 夹具与 close_whenGuardMisses_reportsNotApplied_neverSuccess 逐行一致
+        login(TENANT, false);
+        LsyOrderEntity row = orderRow("PAYING");
+        when(orderMapper.selectOne(any())).thenReturn(row);
+        when(cancelService.closeOrder(row)).thenReturn("CANCELED");
+        when(orderMapper.update(any(), any())).thenReturn(0);
+
+        mockMvc.perform(post(PREFIX + "/" + OTN + "/close"))
+                .andExpect(jsonPath("$.code").value(500));
+
+        verifyNoInteractions(publisher);   // 0 行 = 什么都没改,发了就是让页面白拉
+    }
+
     /** 关单也必须带租户谓词:定位不到就 404,**一个上游关单都不发**。 */
     @Test
     void close_isTenantScoped_andNeverClosesAnotherTenantsOrder() throws Exception {

+ 19 - 1
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCheckoutControllerTest.java

@@ -14,6 +14,7 @@ import com.payment.platform.module.payment.leshouying.service.LsyCheckoutLinkSer
 import com.payment.platform.module.payment.leshouying.service.LsyMerchantService;
 import com.payment.platform.module.payment.leshouying.service.LsyOrderService;
 import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
+import com.payment.platform.module.payment.stream.OrderEventPublisher;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -66,12 +67,13 @@ class LeshouyingCheckoutControllerTest {
     @Mock private LsyMerchantService merchantService;
     @Mock private LsyOrderService orderService;
     @Mock private LsyOrderMapper orderMapper;
+    @Mock private OrderEventPublisher publisher;
 
     private LeshouyingCheckoutController controller;
 
     @BeforeEach
     void setUp() {
-        controller = new LeshouyingCheckoutController(linkService, merchantService, orderService, orderMapper);
+        controller = new LeshouyingCheckoutController(linkService, merchantService, orderService, orderMapper, publisher);
         SecurityContextHolder.clearContext();
     }
 
@@ -213,6 +215,22 @@ class LeshouyingCheckoutControllerTest {
                 "给顾客的是**上游**的支付链接本身,不要包装成我们自己的地址");
     }
 
+    @Test
+    void create_publishesLsyOrderSignal() {
+        // 夹具与 create_assemblesTheOrder_commitsIt_thenCallsTheRealQrPayLeg 逐行一致
+        when(linkService.requireByToken("tok")).thenReturn(link());
+        when(merchantService.requireByMchNoForTenant(MCH_NO, TENANT)).thenReturn(merchant());
+        when(orderService.createQrOrder(any(), any())).thenAnswer(inv -> {
+            LsyOrderEntity o = inv.getArgument(0);
+            o.setCodeUrl("https://qr.alipay.com/abc");
+            return null;
+        });
+
+        controller.create("tok", pay(1234L));
+
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_LSY_ORDER, TENANT);
+    }
+
     @Test
     void create_setsTenantContextForEveryDbOperationAndRestoresItAfterwards() {
         when(linkService.requireByToken("tok")).thenReturn(link());

+ 3 - 1
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenApiErrorContractTest.java

@@ -8,6 +8,7 @@ import com.payment.platform.module.payment.leshouying.client.LeshouyingResponseP
 import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
 import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity;
 import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
+import com.payment.platform.module.payment.stream.OrderEventPublisher;
 import com.payment.platform.module.payment.leshouying.service.LsyCancelService;
 import com.payment.platform.module.payment.leshouying.service.LsyCheckoutLinkService;
 import com.payment.platform.module.payment.leshouying.service.LsyMerchantService;
@@ -74,6 +75,7 @@ class LeshouyingOpenApiErrorContractTest {
     @Mock private LsyMerchantService merchantService;
     @Mock private LsyCheckoutLinkService linkService;
     @Mock private LsyOrderMapper orderMapper;
+    @Mock private OrderEventPublisher publisher;
 
     /** 真实例:本类要验的投影结果不能是 mock。 */
     private final LsyQueryDetailAssembler detailAssembler =
@@ -92,7 +94,7 @@ class LeshouyingOpenApiErrorContractTest {
 
         LeshouyingOpenController controller = new LeshouyingOpenController(orderService, queryService,
                 cancelService, refundService, merchantService, linkService,
-                detailAssembler, orderMapper, injected);
+                detailAssembler, orderMapper, injected, publisher);
 
         // ★ 必须灌入**应用真实的 ObjectMapper**,否则本类测的是一个不存在的环境。
         //   `standaloneSetup` 默认用 `Jackson2ObjectMapperBuilder.json().build()`,它与生产有两处不同:

+ 82 - 3
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenControllerTest.java

@@ -10,6 +10,7 @@ import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity;
 import com.payment.platform.module.payment.leshouying.enums.LsyOrderStatus;
 import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
 import com.payment.platform.module.payment.leshouying.service.*;
+import com.payment.platform.module.payment.stream.OrderEventPublisher;
 import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.common.response.Result;
 import jakarta.validation.Validation;
@@ -48,6 +49,7 @@ class LeshouyingOpenControllerTest {
     @Mock private LsyMerchantService merchantService;
     @Mock private LsyCheckoutLinkService linkService;
     @Mock private LsyOrderMapper orderMapper;
+    @Mock private OrderEventPublisher publisher;
 
     /**
      * **真实实例,不是 mock** —— 本类要钉的正是「上游字段不外泄」这条投影规则,
@@ -66,7 +68,7 @@ class LeshouyingOpenControllerTest {
         request.setAttribute("openapi.apiKeyId", 10L);
         controller = new LeshouyingOpenController(orderService, queryService,
                 cancelService, refundService, merchantService, linkService,
-                detailAssembler, orderMapper, request);
+                detailAssembler, orderMapper, request, publisher);
     }
 
     private LsyMerchantEntity merchant() {
@@ -120,6 +122,57 @@ class LeshouyingOpenControllerTest {
                 "★ 落库必须发生在 mch_no 装配之后 —— V1.8 的 pay_lsy_order.mch_no 是 NOT NULL 且无默认值");
     }
 
+    @Test
+    void barcodePay_publishesLsyOrderSignal() {
+        // 夹具与 barcodePay_mapsDtoToEntityAndReturnsResult 逐行一致(同 mock 同入参)
+        when(merchantService.requireByMchNoForTenant(eq("Y52211090004"), eq(TENANT))).thenReturn(merchant());
+        when(orderService.createBarcodeOrder(any(), eq("134567890123456789"), isNull()))
+                .thenReturn(result("SUCCESS"));
+
+        LsyBarcodePayDTO dto = new LsyBarcodePayDTO();
+        dto.setOutTradeNo("OUT001");
+        dto.setMerchantCode("Y52211090004");
+        dto.setAuthCode("134567890123456789");
+        dto.setTotalAmount(100L);
+
+        controller.barcodePay(dto);
+
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_LSY_ORDER, TENANT);
+    }
+
+    /** 另外两个建单端点各发一次 —— 三个 insert 是三条独立的发布点,删掉任一处本条必红。 */
+    @Test
+    void qrcodeCreate_publishesLsyOrderSignal() {
+        // 夹具与 qrcodeCreate_nullMerchantCode_usesTheOnlyMerchantOfTheTenant 同形
+        when(merchantService.requireByTenant(TENANT)).thenReturn(merchant());
+        when(orderService.createQrOrder(any(), any())).thenReturn(result("PAY_SUCCESS"));
+
+        LsyQrCreateDTO dto = new LsyQrCreateDTO();
+        dto.setOutTradeNo("OUT001");
+        dto.setMerchantCode(null);
+        dto.setTotalAmount(100L);
+
+        controller.qrcodeCreate(dto);
+
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_LSY_ORDER, TENANT);
+    }
+
+    @Test
+    void h5Create_publishesLsyOrderSignal() {
+        // 夹具与 h5Create_withMerchantCode_matchesByCodeAndNeverFallsBackToTenant 同形
+        when(merchantService.requireByMchNoForTenant(eq("Y52211090004"), eq(TENANT))).thenReturn(merchant());
+        when(orderService.createH5Order(any(), any())).thenReturn(result("PAY_SUCCESS"));
+
+        LsyH5CreateDTO dto = new LsyH5CreateDTO();
+        dto.setOutTradeNo("OUT001");
+        dto.setMerchantCode("Y52211090004");
+        dto.setTotalAmount(100L);
+
+        controller.h5Create(dto);
+
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_LSY_ORDER, TENANT);
+    }
+
     // ────── 按商户号寻址(2026-09-30):不传 merchant_code ⇒ 租户唯一那个;传了 ⇒ 按号精确匹配 ──────
 
     @Test
@@ -318,7 +371,7 @@ class LeshouyingOpenControllerTest {
         MockHttpServletRequest anon = new MockHttpServletRequest();
         LeshouyingOpenController c = new LeshouyingOpenController(orderService,
                 queryService, cancelService, refundService, merchantService,
-                linkService, detailAssembler, orderMapper, anon);
+                linkService, detailAssembler, orderMapper, anon, publisher);
 
         assertThrows(BusinessException.class,
                 () -> c.checkoutLink(new LsyCheckoutLinkCreateDTO()));
@@ -366,7 +419,7 @@ class LeshouyingOpenControllerTest {
         MockHttpServletRequest anon = new MockHttpServletRequest();
         LeshouyingOpenController c = new LeshouyingOpenController(orderService,
                 queryService, cancelService, refundService, merchantService,
-                linkService, detailAssembler, orderMapper, anon);
+                linkService, detailAssembler, orderMapper, anon, publisher);
 
         LsyBarcodePayDTO dto = new LsyBarcodePayDTO();
         dto.setOutTradeNo("OUT001");
@@ -548,6 +601,30 @@ class LeshouyingOpenControllerTest {
         verify(orderMapper, never()).updateById(any());
     }
 
+    @Test
+    void orderClose_条件更新命中时_发一条信号() {
+        LsyOrderEntity order = payingOrder();
+        when(orderMapper.selectOne(any())).thenReturn(order);
+        when(cancelService.closeOrder(order)).thenReturn("CLOSED");
+        when(orderMapper.update(any(), any())).thenReturn(1);
+
+        controller.orderClose(closeBody());
+
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_LSY_ORDER, TENANT);
+    }
+
+    @Test
+    void orderClose_条件更新未命中_不发信号() {
+        LsyOrderEntity order = payingOrder();
+        when(orderMapper.selectOne(any())).thenReturn(order);
+        when(cancelService.closeOrder(order)).thenReturn("CLOSED");
+        when(orderMapper.update(any(), any())).thenReturn(0);
+
+        controller.orderClose(closeBody());
+
+        verifyNoInteractions(publisher);   // 一个字节都没改,发了就是让页面白拉
+    }
+
     @Test
     void orderClose_上游判定已支付时一行都不写() {
         // closeOrder 返回 "SUCCESS" = 撤销前查询显示已付款 ⇒ 转退款流程,不改状态。
@@ -563,6 +640,8 @@ class LeshouyingOpenControllerTest {
                 "已支付分支回的是**当前**状态(不是目标状态)");
         verify(orderMapper, never()).update(any(), any());
         verify(orderMapper, never()).updateById(any());
+        // 同一类「守卫没命中就不发」:本支连写都没写,更不该发(补一行,与上面两条新用例同治)
+        verifyNoInteractions(publisher);
     }
 
     /**

+ 30 - 14
java/src/test/java/com/payment/platform/module/payment/leshouying/scheduler/LsySchedulerTest.java

@@ -17,6 +17,7 @@ import com.payment.platform.module.payment.leshouying.enums.LsyRefundStatus;
 import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
 import com.payment.platform.module.payment.leshouying.mapper.LsyRefundMapper;
 import com.payment.platform.module.payment.leshouying.service.*;
+import com.payment.platform.module.payment.stream.OrderEventPublisher;
 import org.apache.ibatis.builder.MapperBuilderAssistant;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -55,6 +56,10 @@ class LsySchedulerTest {
     @Mock private LsyCallbackService callbackService;
     @Mock private LsyOrderService orderService;
     @Mock private LsyRefundService refundService;
+    @Mock private OrderEventPublisher publisher;
+
+    /** 行上的租户 —— 信号按它分桶(只发给该租户的连接 + 超管那条)。 */
+    private static final Long TENANT = 7L;
 
     private LeshouyingProperties props;
 
@@ -82,6 +87,8 @@ class LsySchedulerTest {
         o.setStatus("PAYING");
         o.setQueryCount(queryCount);
         o.setMchNo("Y52211090004");
+        // 租户:发布断言要钉的就是这个值(真实行必然带它,hand-built 夹具得自己补)
+        o.setTenantId(TENANT);
         return o;
     }
 
@@ -103,7 +110,8 @@ class LsySchedulerTest {
         when(orderService.settleFromQuery(eq(o2), any())).thenReturn("SUCCESS");
 
         LsyOrderPollScheduler scheduler = new LsyOrderPollScheduler(
-                orderMapper, queryService, cancelService, callbackService, orderService, props);
+                orderMapper, queryService, cancelService, callbackService,
+                orderService, props, publisher);
 
         assertDoesNotThrow(scheduler::pollBarcodeOrders);
         verify(queryService).queryOrder(o2);
@@ -117,7 +125,7 @@ class LsySchedulerTest {
         when(orderService.settleFromQuery(eq(o), any())).thenReturn("SUCCESS");
 
         new LsyOrderPollScheduler(orderMapper, queryService, cancelService,
-                callbackService, orderService, props)
+                callbackService, orderService, props, publisher)
                 .pollBarcodeOrders();
 
         verify(callbackService).notifyOrder(o);
@@ -131,7 +139,7 @@ class LsySchedulerTest {
         when(orderService.settleFromQuery(eq(o), any())).thenReturn(null);   // 未流转
 
         new LsyOrderPollScheduler(orderMapper, queryService, cancelService,
-                callbackService, orderService, props)
+                callbackService, orderService, props, publisher)
                 .pollBarcodeOrders();
 
         verify(callbackService, never()).notifyOrder(any());
@@ -160,7 +168,7 @@ class LsySchedulerTest {
         when(orderMapper.update(any(), any())).thenReturn(1);                // 条件更新命中 1 行
 
         new LsyOrderPollScheduler(orderMapper, queryService, cancelService,
-                callbackService, orderService, props)
+                callbackService, orderService, props, publisher)
                 .pollBarcodeOrders();
 
         var captor = org.mockito.ArgumentCaptor.forClass(UpdateWrapper.class);
@@ -171,6 +179,8 @@ class LsySchedulerTest {
         verify(orderMapper, never()).updateById(any());
         verify(cancelService).closeOrder(o);
         verify(callbackService).notifyOrder(o);
+        // 真的改了这一行才发信号(命中 1 行)
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_LSY_ORDER, TENANT);
         assertEquals("CANCELED", o.getStatus());
     }
 
@@ -192,7 +202,7 @@ class LsySchedulerTest {
         when(orderMapper.update(any(), any())).thenReturn(0);      // 往返期间已被回调写成终态
 
         new LsyOrderPollScheduler(orderMapper, queryService, cancelService,
-                callbackService, orderService, props)
+                callbackService, orderService, props, publisher)
                 .pollBarcodeOrders();
 
         var captor = org.mockito.ArgumentCaptor.forClass(UpdateWrapper.class);
@@ -203,6 +213,7 @@ class LsySchedulerTest {
 
         verify(orderMapper, never()).updateById(any());
         verify(callbackService, never()).notifyOrder(any());
+        verifyNoInteractions(publisher);   // 一行都没改,发了就是让页面白拉
         assertEquals("PAYING", o.getStatus(), "0 行时内存对象也不得被改写成 CANCELED");
     }
 
@@ -219,7 +230,7 @@ class LsySchedulerTest {
         when(orderMapper.selectById(1L)).thenReturn(won);
 
         new LsyOrderPollScheduler(orderMapper, queryService, cancelService,
-                callbackService, orderService, props)
+                callbackService, orderService, props, publisher)
                 .pollBarcodeOrders();
 
         verify(cancelService, never()).closeOrder(any());
@@ -231,7 +242,8 @@ class LsySchedulerTest {
         when(orderMapper.selectList(any())).thenReturn(List.of());
 
         LsyOrderPollScheduler scheduler = new LsyOrderPollScheduler(
-                orderMapper, queryService, cancelService, callbackService, orderService, props);
+                orderMapper, queryService, cancelService, callbackService,
+                orderService, props, publisher);
         scheduler.pollBarcodeOrders();
 
         verify(queryService, never()).queryOrder(any());
@@ -246,7 +258,7 @@ class LsySchedulerTest {
 
         LsyReconcileScheduler scheduler = new LsyReconcileScheduler(
                 orderMapper, refundMapper, queryService, cancelService, callbackService,
-                orderService, refundService, props);
+                orderService, refundService, props, publisher);
 
         assertDoesNotThrow(() -> scheduler.applyCloseResult(o));
         verify(orderMapper, never()).updateById(any());
@@ -262,7 +274,7 @@ class LsySchedulerTest {
 
         LsyReconcileScheduler scheduler = new LsyReconcileScheduler(
                 orderMapper, refundMapper, queryService, cancelService, callbackService,
-                orderService, refundService, props);
+                orderService, refundService, props, publisher);
         scheduler.reconcilePendingOrders();
 
         verify(callbackService).notifyOrder(o);
@@ -295,7 +307,7 @@ class LsySchedulerTest {
 
         LsyReconcileScheduler scheduler = new LsyReconcileScheduler(
                 orderMapper, refundMapper, queryService, cancelService, callbackService,
-                orderService, refundService, props);
+                orderService, refundService, props, publisher);
         assertDoesNotThrow(scheduler::expireStaleOrders);
 
         var captor = org.mockito.ArgumentCaptor.forClass(UpdateWrapper.class);
@@ -325,6 +337,7 @@ class LsySchedulerTest {
         // 守卫不许落在返回值上 ⇒ 这条写入路径不得再出现无条件的 updateById
         verify(orderMapper, never()).updateById(any());
         verify(callbackService, never()).notifyOrder(any());
+        verifyNoInteractions(publisher);   // 0 行 ⇒ 页面没什么可刷的
         assertEquals("PAYING", o.getStatus(), "0 行时内存对象也不得被改写成 EXPIRED");
     }
 
@@ -345,10 +358,11 @@ class LsySchedulerTest {
         when(orderMapper.update(any(), any())).thenReturn(1);
 
         new LsyReconcileScheduler(orderMapper, refundMapper, queryService, cancelService,
-                callbackService, orderService, refundService, props)
+                callbackService, orderService, refundService, props, publisher)
                 .expireStaleOrders();
 
         verify(callbackService).notifyOrder(o);
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_LSY_ORDER, TENANT);
         assertEquals("EXPIRED", o.getStatus());
     }
 
@@ -372,7 +386,7 @@ class LsySchedulerTest {
         when(orderMapper.update(any(), any())).thenReturn(0);
 
         new LsyReconcileScheduler(orderMapper, refundMapper, queryService, cancelService,
-                callbackService, orderService, refundService, props).applyCloseResult(o);
+                callbackService, orderService, refundService, props, publisher).applyCloseResult(o);
 
         var captor = org.mockito.ArgumentCaptor.forClass(UpdateWrapper.class);
         verify(orderMapper, atLeastOnce()).update(any(), captor.capture());
@@ -385,6 +399,7 @@ class LsySchedulerTest {
 
         verify(orderMapper, never()).updateById(any());
         verify(callbackService, never()).notifyOrder(any());
+        verifyNoInteractions(publisher);   // 0 行 ⇒ 页面没什么可刷的
         assertEquals("PAYING", o.getStatus(), "0 行时内存对象也不得被改写成 CLOSED");
     }
 
@@ -401,10 +416,11 @@ class LsySchedulerTest {
         when(orderMapper.update(any(), any())).thenReturn(1);
 
         new LsyReconcileScheduler(orderMapper, refundMapper, queryService, cancelService,
-                callbackService, orderService, refundService, props).applyCloseResult(o);
+                callbackService, orderService, refundService, props, publisher).applyCloseResult(o);
 
         assertEquals("CLOSED", o.getStatus());
         verify(callbackService).notifyOrder(o);
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_LSY_ORDER, TENANT);
         verify(orderMapper, never()).updateById(any());
     }
 
@@ -518,7 +534,7 @@ class LsySchedulerTest {
 
     private LsyReconcileScheduler scheduler() {
         return new LsyReconcileScheduler(orderMapper, refundMapper, queryService, cancelService,
-                callbackService, orderService, refundService, props);
+                callbackService, orderService, refundService, props, publisher);
     }
 
     private LsyRefundEntity pendingRefund() {