Browse Source

Merge branch 'test-202609v1' into test-202609v2

qmj 1 day ago
parent
commit
68d8b8de38

+ 13 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/PayMethodSwitchController.java

@@ -7,6 +7,8 @@ import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.JwtUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestHeader;
@@ -23,6 +25,8 @@ import org.springframework.web.bind.annotation.RestController;
 @RequestMapping("/pay")
 public class PayMethodSwitchController {
 
+    private static final Logger logger = LoggerFactory.getLogger(PayMethodSwitchController.class);
+
     private final PayMethodSwitchService switchService;
 
     public PayMethodSwitchController(PayMethodSwitchService switchService) {
@@ -44,7 +48,16 @@ public class PayMethodSwitchController {
         } catch (ServiceException exception) {
             // 业务拒绝(订单不可支付/方式非法/闸门限制)携带国际化文案原样返回
             return AjaxResult.error(exception.getMessage());
+        } catch (com.ruoyi.app.omgpay.OmgPaymentBusinessException exception) {
+            // 渠道业务拒绝(如已有激活尝试)按 OMG 既有约定翻译,不再落通用文案误导排障
+            logger.warn("pay switch rejected by channel orderId={}, code={}",
+                    request == null ? null : request.getOrderId(), exception.getCode());
+            return AjaxResult.error(MessageUtils.message(exception.getMessageKey()),
+                    new com.ruoyi.app.omgpay.dto.OmgPaymentErrorResponse(exception.getCode().name()));
         } catch (Exception exception) {
+            // 兜底前必须留痕:真实异常(网关调用/NPE/SQL)只有这里能看到,不打日志等于盲修
+            logger.error("pay switch failed orderId={}",
+                    request == null ? null : request.getOrderId(), exception);
             return AjaxResult.error(MessageUtils.message("no.pay.switch.failed"));
         }
     }

+ 8 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/pay/PayMethodSwitchService.java

@@ -2,6 +2,7 @@ package com.ruoyi.app.pay;
 
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.ruoyi.app.omgpay.OmgPaymentCreateService;
+import com.ruoyi.app.omgpay.OmgPaymentRetryService;
 import com.ruoyi.app.omgpay.OmgPaymentMethod;
 import com.ruoyi.app.order.OrderLifecycleService;
 import com.ruoyi.app.pay.dto.PayMethodSwitchRequest;
@@ -40,18 +41,18 @@ public class PayMethodSwitchService {
     private static final String PAY_TYPE_LINE_PAY = "3";
 
     private final IPosOrderService orderService;
-    private final OmgPaymentCreateService omgPaymentCreateService;
+    private final OmgPaymentRetryService omgPaymentRetryService;
     private final LinePayService linePayService;
     private final OrderLogHelper orderLogHelper;
     private final IInfoUserService infoUserService;
 
     public PayMethodSwitchService(IPosOrderService orderService,
-                                  OmgPaymentCreateService omgPaymentCreateService,
+                                  OmgPaymentRetryService omgPaymentRetryService,
                                   LinePayService linePayService,
                                   OrderLogHelper orderLogHelper,
                                   IInfoUserService infoUserService) {
         this.orderService = orderService;
-        this.omgPaymentCreateService = omgPaymentCreateService;
+        this.omgPaymentRetryService = omgPaymentRetryService;
         this.linePayService = linePayService;
         this.orderLogHelper = orderLogHelper;
         this.infoUserService = infoUserService;
@@ -90,10 +91,12 @@ public class PayMethodSwitchService {
     /** 按新方式路由到对应渠道拉起支付;到付/现金无支付环节返回 null。 */
     private Object createPayment(Long userId, String orderId, String method) {
         if (OrderLifecycleService.isCardWalletPayType(method)) {
-            // 信用卡(2)/Apple Pay(5) 同组同商(023),都走 OMG 创建;payType 已回写,组内校验自然通过
+            // 信用卡(2)/Apple Pay(5) 同组同商(023),都走 OMG;走 retry 路径而非裸 create:
+            // 组内切换(如 5->2)时旧尝试仍激活,retry 会先向网关核实未付再替换重建,
+            // 避免 PAYMENT_ATTEMPT_EXISTS 拒绝(payType 已回写,组内校验自然通过)
             OmgPaymentMethod omgMethod = OrderLifecycleService.PAY_TYPE_APPLE_PAY.equals(method)
                     ? OmgPaymentMethod.APPLE_PAY : OmgPaymentMethod.CREDIT;
-            return omgPaymentCreateService.create(userId, orderId, omgMethod).response();
+            return omgPaymentRetryService.retry(userId, orderId, omgMethod).response();
         }
         if (PAY_TYPE_LINE_PAY.equals(method)) {
             return linePayService.create(userId, orderId);

+ 27 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/event/PushEventListener.java

@@ -1,6 +1,8 @@
 package com.ruoyi.app.utils.event;
 
 
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
 import com.ruoyi.system.domain.PushMessage;
 import com.ruoyi.system.service.IPushMessageService;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -23,7 +25,32 @@ public class PushEventListener  implements ApplicationListener<PushEvent> {
         pushMessage.setBody(event.getBody());
         pushMessage.setUserId(event.getUserId());
         pushMessage.setTime(new java.util.Date());
+        applyOrderPointer(pushMessage);
         pushMessageService.insertPushMessage(pushMessage);
 
     }
+
+    /**
+     * 从推送 body JSON(OrderPushBodyDto 结构)解析订单指针 ddId/pushType 存列,
+     * 供消息列表反查订单展示信息;非订单类推送(body 非订单 JSON)解析失败时指针留空。
+     */
+    private void applyOrderPointer(PushMessage pushMessage) {
+        String body = pushMessage.getBody();
+        if (body == null || body.isEmpty()) {
+            return;
+        }
+        try {
+            JSONObject json = JSON.parseObject(body);
+            String ddId = json.getString("ddId");
+            Integer pushType = json.getInteger("pushType");
+            if (ddId != null && !ddId.isEmpty()) {
+                pushMessage.setDdId(ddId);
+            }
+            if (pushType != null) {
+                pushMessage.setPushType(pushType);
+            }
+        } catch (Exception ignored) {
+            // body 不是订单 JSON(如视频通话推送),不阻断入库
+        }
+    }
 }

+ 2 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/PushMessageController.java

@@ -48,6 +48,8 @@ public class PushMessageController extends BaseController
         LambdaQueryWrapper<PushMessage> queryWrapper = new LambdaQueryWrapper<>();
         queryWrapper.eq(PushMessage::getUserId,uid).orderByDesc(PushMessage::getTime);
         IPage<PushMessage> data = pushMessageService.page(page,queryWrapper);
+        // 按订单指针回填商家名/地址,供骑手消息卡片展示
+        pushMessageService.attachOrderInfo(data.getRecords());
         return success(data);
     }
 

+ 19 - 17
ruoyi-admin/src/test/java/com/ruoyi/app/pay/PayMethodSwitchServiceTest.java

@@ -3,6 +3,7 @@ package com.ruoyi.app.pay;
 import com.baomidou.mybatisplus.core.conditions.Wrapper;
 import com.ruoyi.app.omgpay.OmgPaymentCreateOutcome;
 import com.ruoyi.app.omgpay.OmgPaymentCreateService;
+import com.ruoyi.app.omgpay.OmgPaymentRetryService;
 import com.ruoyi.app.omgpay.OmgPaymentMethod;
 import com.ruoyi.app.omgpay.dto.OmgCreatePaymentResponse;
 import com.ruoyi.app.pay.dto.PayMethodSwitchRequest;
@@ -90,7 +91,7 @@ class PayMethodSwitchServiceTest {
     @Test
     void switchLinePayOrderToOfflineUpdatesPayTypeWritesLogAndSkipsPayment() {
         IPosOrderService orderService = mock(IPosOrderService.class);
-        OmgPaymentCreateService omgPaymentCreateService = mock(OmgPaymentCreateService.class);
+        OmgPaymentRetryService omgPaymentRetryService = mock(OmgPaymentRetryService.class);
         LinePayService linePayService = mock(LinePayService.class);
         OrderLogHelper orderLogHelper = mock(OrderLogHelper.class);
         IInfoUserService infoUserService = mock(IInfoUserService.class);
@@ -102,7 +103,7 @@ class PayMethodSwitchServiceTest {
         when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
         when(orderService.updateById(any(PosOrder.class))).thenReturn(true);
         PayMethodSwitchService service = new PayMethodSwitchService(orderService,
-                omgPaymentCreateService, linePayService, orderLogHelper, infoUserService);
+                omgPaymentRetryService, linePayService, orderLogHelper, infoUserService);
 
         AjaxResult result = service.switchMethod(7L, request("dd-1", "1"));
 
@@ -122,7 +123,7 @@ class PayMethodSwitchServiceTest {
         order.setType(0L);
         when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
         PayMethodSwitchService service = new PayMethodSwitchService(orderService,
-                mock(OmgPaymentCreateService.class), mock(LinePayService.class),
+                mock(OmgPaymentRetryService.class), mock(LinePayService.class),
                 mock(OrderLogHelper.class), mock(IInfoUserService.class));
 
         ServiceException exception = assertThrows(ServiceException.class,
@@ -135,22 +136,23 @@ class PayMethodSwitchServiceTest {
     @SuppressWarnings({"unchecked", "rawtypes"})
     void switchToCreditCardRewritesPayTypeBeforeOmgCreate() {
         IPosOrderService orderService = mock(IPosOrderService.class);
-        OmgPaymentCreateService omgPaymentCreateService = mock(OmgPaymentCreateService.class);
-        when(omgPaymentCreateService.create(eq(7L), eq("dd-1"), eq(OmgPaymentMethod.CREDIT)))
+        OmgPaymentRetryService omgPaymentRetryService = mock(OmgPaymentRetryService.class);
+        when(omgPaymentRetryService.retry(eq(7L), eq("dd-1"), eq(OmgPaymentMethod.CREDIT)))
                 .thenReturn(new OmgPaymentCreateOutcome(mock(OmgCreatePaymentResponse.class), 1L, "dd-1", 7L, 5L, 90, "abcde***fghi"));
         PosOrder order = baseOrder();
         when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
         when(orderService.updateById(any(PosOrder.class))).thenReturn(true);
         PayMethodSwitchService service = new PayMethodSwitchService(orderService,
-                omgPaymentCreateService, mock(LinePayService.class), mock(OrderLogHelper.class),
+                omgPaymentRetryService, mock(LinePayService.class), mock(OrderLogHelper.class),
                 mock(IInfoUserService.class));
 
         AjaxResult result = service.switchMethod(7L, request("dd-1", "2"));
 
-        // 顺序约束:必须先回写 payType 再调 OMG 创建,否则创建校验按旧 payType 拒绝
-        InOrder inOrder = inOrder(orderService, omgPaymentCreateService);
+        // 顺序约束:先回写 payType 再拉起渠道;走 retry 路径:旧 Apple Pay 尝试仍激活时
+        // 替换重建而非 PAYMENT_ATTEMPT_EXISTS 死锁(组内 5->2 切换线上事故场景)
+        InOrder inOrder = inOrder(orderService, omgPaymentRetryService);
         inOrder.verify(orderService).updateById(any(PosOrder.class));
-        inOrder.verify(omgPaymentCreateService).create(eq(7L), eq("dd-1"), eq(OmgPaymentMethod.CREDIT));
+        inOrder.verify(omgPaymentRetryService).retry(eq(7L), eq("dd-1"), eq(OmgPaymentMethod.CREDIT));
         Map<?, ?> data = (Map<?, ?>) result.get("data");
         assertEquals("2", data.get("payType"));
         assertNotNull(data.get("payParams"), "在线方式应带渠道支付参数");
@@ -159,20 +161,20 @@ class PayMethodSwitchServiceTest {
     @Test
     void sameMethodSkipsRewriteButStillDelegates() {
         IPosOrderService orderService = mock(IPosOrderService.class);
-        OmgPaymentCreateService omgPaymentCreateService = mock(OmgPaymentCreateService.class);
-        when(omgPaymentCreateService.create(any(), anyString(), any(OmgPaymentMethod.class)))
+        OmgPaymentRetryService omgPaymentRetryService = mock(OmgPaymentRetryService.class);
+        when(omgPaymentRetryService.retry(any(), anyString(), any(OmgPaymentMethod.class)))
                 .thenReturn(new OmgPaymentCreateOutcome(mock(OmgCreatePaymentResponse.class), 1L, "dd-1", 7L, 5L, 90, "abcde***fghi"));
         PosOrder order = baseOrder();
         order.setPayType("2");
         when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
         PayMethodSwitchService service = new PayMethodSwitchService(orderService,
-                omgPaymentCreateService, mock(LinePayService.class), mock(OrderLogHelper.class),
+                omgPaymentRetryService, mock(LinePayService.class), mock(OrderLogHelper.class),
                 mock(IInfoUserService.class));
 
         service.switchMethod(7L, request("dd-1", "2"));
 
         verify(orderService, never()).updateById(any(PosOrder.class));
-        verify(omgPaymentCreateService, times(1)).create(eq(7L), eq("dd-1"), eq(OmgPaymentMethod.CREDIT));
+        verify(omgPaymentRetryService, times(1)).retry(eq(7L), eq("dd-1"), eq(OmgPaymentMethod.CREDIT));
     }
 
     @Test
@@ -182,7 +184,7 @@ class PayMethodSwitchServiceTest {
         order.setPayStatus(1L);
         when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
         PayMethodSwitchService service = new PayMethodSwitchService(orderService,
-                mock(OmgPaymentCreateService.class), mock(LinePayService.class),
+                mock(OmgPaymentRetryService.class), mock(LinePayService.class),
                 mock(OrderLogHelper.class), mock(IInfoUserService.class));
 
         ServiceException exception = assertThrows(ServiceException.class,
@@ -194,7 +196,7 @@ class PayMethodSwitchServiceTest {
     void unknownMethodIsRejectedBeforeOrderLookup() {
         IPosOrderService orderService = mock(IPosOrderService.class);
         PayMethodSwitchService service = new PayMethodSwitchService(orderService,
-                mock(OmgPaymentCreateService.class), mock(LinePayService.class),
+                mock(OmgPaymentRetryService.class), mock(LinePayService.class),
                 mock(OrderLogHelper.class), mock(IInfoUserService.class));
 
         ServiceException exception = assertThrows(ServiceException.class,
@@ -210,7 +212,7 @@ class PayMethodSwitchServiceTest {
         order.setUserId(8L);
         when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
         PayMethodSwitchService service = new PayMethodSwitchService(orderService,
-                mock(OmgPaymentCreateService.class), mock(LinePayService.class),
+                mock(OmgPaymentRetryService.class), mock(LinePayService.class),
                 mock(OrderLogHelper.class), mock(IInfoUserService.class));
 
         ServiceException exception = assertThrows(ServiceException.class,
@@ -225,7 +227,7 @@ class PayMethodSwitchServiceTest {
         order.setParentDdId("dd-parent");
         when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
         PayMethodSwitchService service = new PayMethodSwitchService(orderService,
-                mock(OmgPaymentCreateService.class), mock(LinePayService.class),
+                mock(OmgPaymentRetryService.class), mock(LinePayService.class),
                 mock(OrderLogHelper.class), mock(IInfoUserService.class));
 
         ServiceException exception = assertThrows(ServiceException.class,

+ 70 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/utils/event/PushEventListenerTest.java

@@ -0,0 +1,70 @@
+package com.ruoyi.app.utils.event;
+
+import com.ruoyi.system.domain.PushMessage;
+import com.ruoyi.system.service.IPushMessageService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+/**
+ * push_message 订单指针字段:listener 入库时从 body JSON(OrderPushBodyDto 结构)
+ * 解析 ddId/pushType 存列;非订单类推送(body 非订单 JSON/为空)容错存 NULL,不影响入库。
+ */
+class PushEventListenerTest {
+
+    private final IPushMessageService pushMessageService = mock(IPushMessageService.class);
+    private final PushEventListener listener = new PushEventListener();
+
+    @BeforeEach
+    void setUp() {
+        ReflectionTestUtils.setField(listener, "pushMessageService", pushMessageService);
+    }
+
+    @Test
+    void extractsDdIdAndPushTypeFromBodyJsonWhenInserting() {
+        listener.onApplicationEvent(new PushEvent(9L, "訊息", "您有新的外送訂單",
+                "{\"ddId\":\"991790045286244\",\"state\":\"0\",\"type\":-1,\"pushType\":1}"));
+
+        ArgumentCaptor<PushMessage> captor = ArgumentCaptor.forClass(PushMessage.class);
+        verify(pushMessageService).insertPushMessage(captor.capture());
+        assertEquals("991790045286244", captor.getValue().getDdId());
+        assertEquals(Integer.valueOf(1), captor.getValue().getPushType());
+    }
+
+    @Test
+    void extractsFlashOrderNoAsDdIdWithPushTypeThree() {
+        listener.onApplicationEvent(new PushEvent(9L, "訊息", "您有新的快送訂單",
+                "{\"ddId\":\"F20260924001\",\"state\":\"WAITING_ACCEPTANCE\",\"type\":0,\"pushType\":3}"));
+
+        ArgumentCaptor<PushMessage> captor = ArgumentCaptor.forClass(PushMessage.class);
+        verify(pushMessageService).insertPushMessage(captor.capture());
+        assertEquals("F20260924001", captor.getValue().getDdId());
+        assertEquals(Integer.valueOf(3), captor.getValue().getPushType());
+    }
+
+    @Test
+    void keepsNullPointersWhenBodyIsNotOrderJson() {
+        listener.onApplicationEvent(new PushEvent(9L, "視頻通話", "來電提醒", "not-a-json"));
+
+        ArgumentCaptor<PushMessage> captor = ArgumentCaptor.forClass(PushMessage.class);
+        verify(pushMessageService).insertPushMessage(captor.capture());
+        assertNull(captor.getValue().getDdId());
+        assertNull(captor.getValue().getPushType());
+    }
+
+    @Test
+    void keepsNullPointersWhenBodyIsNull() {
+        listener.onApplicationEvent(new PushEvent(9L, "訊息", "系統通知", null));
+
+        ArgumentCaptor<PushMessage> captor = ArgumentCaptor.forClass(PushMessage.class);
+        verify(pushMessageService).insertPushMessage(captor.capture());
+        assertNull(captor.getValue().getDdId());
+        assertNull(captor.getValue().getPushType());
+    }
+}

+ 15 - 0
ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/PushMessageControllerTest.java

@@ -23,6 +23,7 @@ import org.springframework.context.support.StaticMessageSource;
 import org.springframework.test.util.ReflectionTestUtils;
 
 import java.util.ArrayList;
+import java.util.List;
 import java.util.Locale;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -88,6 +89,20 @@ class PushMessageControllerTest {
         verify(pushMessageService, never()).update(any(Wrapper.class));
     }
 
+    @Test
+    void listEndpointEnrichesPageRecordsWithOrderInfo() {
+        Page<PushMessage> page = new Page<>(1, 10);
+        List<PushMessage> records = new ArrayList<>();
+        records.add(new PushMessage());
+        page.setRecords(records);
+        when(pushMessageService.page(any(Page.class), any(Wrapper.class))).thenReturn(page);
+
+        controller.getPushMessageList(JwtUtil.token("9", "user"), 1, 10);
+
+        // 分页查完后按订单指针回填商家名/地址,仍返回同一分页对象
+        verify(pushMessageService).attachOrderInfo(records);
+    }
+
     @Test
     void unreadCountEndpointCountsOnlyUnreadForTokenUser() {
         when(pushMessageService.count(any(Wrapper.class))).thenReturn(5L);

+ 16 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/PushMessage.java

@@ -56,6 +56,22 @@ public class PushMessage
     /** 是否已读(0未读 1已读) */
     private Integer isRead;
 
+    /** 关联订单号(外卖ddId/闪送orderNo) */
+    private String ddId;
+
+    /** 推送业务类型 1外卖 2充值 3闪送 */
+    private Integer pushType;
+
+    /** 以下为列表展示回填字段,不落库 */
+    private transient String shopName;
+    private transient String shAddress;
+    /** 订单缩略图:外卖=第一行商品图(food 快照 image 键);闪送=寄件凭证第一张 */
+    private transient String orderImage;
+    private transient String pickupAddress;
+    private transient String pickupAddressDetail;
+    private transient String deliveryAddress;
+    private transient String deliveryAddressDetail;
+
     public void setId(Long id)
     {
         this.id = id;

+ 7 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/IPushMessageService.java

@@ -59,4 +59,11 @@ public interface IPushMessageService extends IService<PushMessage>
      * @return 结果
      */
     public int deletePushMessageById(Long id);
+
+    /**
+     * 按订单指针回填展示信息:外卖消息取商家名+收货地址,闪送消息取取件/收件地址
+     *
+     * @param messages 分页查出的当页消息
+     */
+    public void attachOrderInfo(List<PushMessage> messages);
 }

+ 188 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PushMessageServiceImpl.java

@@ -1,12 +1,30 @@
 package com.ruoyi.system.service.impl;
 
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.common.utils.StringUtils;
 import com.ruoyi.system.mapper.PushMessageMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper;
+import com.ruoyi.system.mapper.flash.FlashDeliveryOrderImageMapper;
+import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.domain.PushMessage;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrderImage;
 import com.ruoyi.system.service.IPushMessageService;
 
 /**
@@ -21,6 +39,15 @@ public class PushMessageServiceImpl extends ServiceImpl<BaseMapper<PushMessage>,
     @Autowired
     private PushMessageMapper pushMessageMapper;
 
+    @Autowired
+    private PosOrderMapper posOrderMapper;
+
+    @Autowired
+    private FlashDeliveryOrderMapper flashDeliveryOrderMapper;
+
+    @Autowired
+    private FlashDeliveryOrderImageMapper flashDeliveryOrderImageMapper;
+
     /**
      * 查询推送消息
      *
@@ -92,4 +119,165 @@ public class PushMessageServiceImpl extends ServiceImpl<BaseMapper<PushMessage>,
     {
         return pushMessageMapper.deletePushMessageById(id);
     }
+
+    /**
+     * 按订单指针回填展示信息:外卖消息取商家名+收货地址,闪送消息取取件/收件地址
+     *
+     * @param messages 分页查出的当页消息
+     */
+    @Override
+    public void attachOrderInfo(List<PushMessage> messages)
+    {
+        if (messages == null || messages.isEmpty())
+        {
+            return;
+        }
+        Set<String> foodDdIds = new LinkedHashSet<>();
+        Set<String> flashOrderNos = new LinkedHashSet<>();
+        collectOrderPointers(messages, foodDdIds, flashOrderNos);
+        Map<String, PosOrder> foodOrders = foodDdIds.isEmpty() ? Collections.emptyMap()
+                : posOrderMapper.selectList(new LambdaQueryWrapper<PosOrder>().in(PosOrder::getDdId, foodDdIds))
+                .stream().filter(order -> order.getDdId() != null)
+                .collect(Collectors.toMap(PosOrder::getDdId, Function.identity(), (first, second) -> first));
+        Map<String, FlashDeliveryOrder> flashOrders = flashOrderNos.isEmpty() ? Collections.emptyMap()
+                : flashDeliveryOrderMapper.selectList(new LambdaQueryWrapper<FlashDeliveryOrder>().in(FlashDeliveryOrder::getOrderNo, flashOrderNos))
+                .stream().filter(order -> order.getOrderNo() != null)
+                .collect(Collectors.toMap(FlashDeliveryOrder::getOrderNo, Function.identity(), (first, second) -> first));
+        Map<Long, String> flashFirstImages = firstSenderImages(flashOrders.values());
+        fillOrderInfo(messages, foodOrders, flashOrders, flashFirstImages);
+    }
+
+    /** 收集当页消息的订单指针:push_type=1 归入外卖单号,push_type=3 归入闪送单号。 */
+    private void collectOrderPointers(List<PushMessage> messages, Set<String> foodDdIds, Set<String> flashOrderNos)
+    {
+        for (PushMessage message : messages)
+        {
+            if (message == null || StringUtils.isEmpty(message.getDdId()))
+            {
+                continue;
+            }
+            if (Integer.valueOf(1).equals(message.getPushType()))
+            {
+                foodDdIds.add(message.getDdId());
+            }
+            else if (Integer.valueOf(3).equals(message.getPushType()))
+            {
+                flashOrderNos.add(message.getDdId());
+            }
+        }
+    }
+
+    /** 按指针把商家名/地址/缩略图写回消息;订单已删或不存在的消息保持原样。 */
+    private void fillOrderInfo(List<PushMessage> messages, Map<String, PosOrder> foodOrders,
+                               Map<String, FlashDeliveryOrder> flashOrders, Map<Long, String> flashFirstImages)
+    {
+        for (PushMessage message : messages)
+        {
+            if (message == null || StringUtils.isEmpty(message.getDdId()))
+            {
+                continue;
+            }
+            if (Integer.valueOf(1).equals(message.getPushType()))
+            {
+                PosOrder order = foodOrders.get(message.getDdId());
+                if (order != null)
+                {
+                    message.setShopName(order.getPosName());
+                    message.setShAddress(order.getShAddress());
+                    message.setOrderImage(firstFoodImage(order.getFood()));
+                }
+            }
+            else if (Integer.valueOf(3).equals(message.getPushType()))
+            {
+                FlashDeliveryOrder order = flashOrders.get(message.getDdId());
+                if (order != null)
+                {
+                    message.setPickupAddress(order.getPickupAddress());
+                    message.setPickupAddressDetail(order.getPickupAddressDetail());
+                    message.setDeliveryAddress(order.getDeliveryAddress());
+                    message.setDeliveryAddressDetail(order.getDeliveryAddressDetail());
+                    if (order.getId() != null)
+                    {
+                        message.setOrderImage(flashFirstImages.get(order.getId()));
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * 外卖订单缩略图:food 快照数组第一行的 image 键(行结构由 App 下单传入,
+     * 含 id/name/image/price/otherPrice/number 等);food 为空或非法返回 null。
+     */
+    private String firstFoodImage(String food)
+    {
+        if (StringUtils.isEmpty(food))
+        {
+            return null;
+        }
+        try
+        {
+            JSONArray rows = JSONArray.parseArray(food);
+            if (rows == null || rows.isEmpty())
+            {
+                return null;
+            }
+            JSONObject first = rows.getJSONObject(0);
+            String image = first == null ? null : first.getString("image");
+            return StringUtils.isEmpty(image) ? null : image;
+        }
+        catch (Exception e)
+        {
+            return null;
+        }
+    }
+
+    /** 闪送订单缩略图:批量查寄件凭证(SENDER),每单取 sortOrder 最小的一张;无凭证返回空。 */
+    private Map<Long, String> firstSenderImages(Collection<FlashDeliveryOrder> orders)
+    {
+        if (orders == null || orders.isEmpty())
+        {
+            return Collections.emptyMap();
+        }
+        Set<Long> orderIds = new LinkedHashSet<>();
+        for (FlashDeliveryOrder order : orders)
+        {
+            if (order != null && order.getId() != null)
+            {
+                orderIds.add(order.getId());
+            }
+        }
+        if (orderIds.isEmpty())
+        {
+            return Collections.emptyMap();
+        }
+        List<FlashDeliveryOrderImage> images = flashDeliveryOrderImageMapper.selectList(
+                new LambdaQueryWrapper<FlashDeliveryOrderImage>()
+                        .in(FlashDeliveryOrderImage::getOrderId, orderIds)
+                        .eq(FlashDeliveryOrderImage::getProofType, "SENDER"));
+        if (images == null || images.isEmpty())
+        {
+            return Collections.emptyMap();
+        }
+        Map<Long, FlashDeliveryOrderImage> firstImages = new HashMap<>();
+        for (FlashDeliveryOrderImage image : images)
+        {
+            if (image == null || image.getOrderId() == null || StringUtils.isEmpty(image.getImageUrl())
+                    || !"SENDER".equals(image.getProofType()))
+            {
+                continue;
+            }
+            firstImages.merge(image.getOrderId(), image,
+                    (current, candidate) -> sortKey(candidate) < sortKey(current) ? candidate : current);
+        }
+        Map<Long, String> result = new HashMap<>();
+        firstImages.forEach((orderId, image) -> result.put(orderId, image.getImageUrl()));
+        return result;
+    }
+
+    /** 凭证展示顺序;sortOrder 缺失时排最后。 */
+    private int sortKey(FlashDeliveryOrderImage image)
+    {
+        return image.getSortOrder() == null ? Integer.MAX_VALUE : image.getSortOrder();
+    }
 }

+ 9 - 1
ruoyi-system/src/main/resources/mapper/system/PushMessageMapper.xml

@@ -12,10 +12,12 @@
         <result property="body"    column="body"    />
         <result property="time"    column="time"    />
         <result property="isRead"    column="is_read"    />
+        <result property="ddId"    column="dd_id"    />
+        <result property="pushType"    column="push_type"    />
     </resultMap>
 
     <sql id="selectPushMessageVo">
-        select id, user_id, title, content, body, time, is_read from push_message
+        select id, user_id, title, content, body, time, is_read, dd_id, push_type from push_message
     </sql>
 
     <select id="selectPushMessageList" parameterType="PushMessage" resultMap="PushMessageResult">
@@ -44,6 +46,8 @@
             <if test="body != null">body,</if>
             <if test="time != null">time,</if>
             <if test="isRead != null">is_read,</if>
+            <if test="ddId != null">dd_id,</if>
+            <if test="pushType != null">push_type,</if>
         </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="userId != null">#{userId},</if>
@@ -52,6 +56,8 @@
             <if test="body != null">#{body},</if>
             <if test="time != null">#{time},</if>
             <if test="isRead != null">#{isRead},</if>
+            <if test="ddId != null">#{ddId},</if>
+            <if test="pushType != null">#{pushType},</if>
         </trim>
     </insert>
 
@@ -64,6 +70,8 @@
             <if test="body != null">body = #{body},</if>
             <if test="time != null">time = #{time},</if>
             <if test="isRead != null">is_read = #{isRead},</if>
+            <if test="ddId != null">dd_id = #{ddId},</if>
+            <if test="pushType != null">push_type = #{pushType},</if>
         </trim>
         where id = #{id}
     </update>

+ 218 - 0
ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PushMessageServiceImplTest.java

@@ -0,0 +1,218 @@
+package com.ruoyi.system.service.impl;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PushMessage;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrderImage;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import com.ruoyi.system.mapper.flash.FlashDeliveryOrderImageMapper;
+import com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 消息列表订单信息回填:外卖消息(push_type=1)批量反查 pos_order 取商家名+收货地址;
+ * 闪送消息(push_type=3)反查 flash_delivery_order 取取件/收件地址;
+ * 无订单指针、非订单类型或订单已删时不回填,也不报错。
+ */
+class PushMessageServiceImplTest {
+
+    private final PosOrderMapper posOrderMapper = mock(PosOrderMapper.class);
+    private final FlashDeliveryOrderMapper flashDeliveryOrderMapper = mock(FlashDeliveryOrderMapper.class);
+    private final FlashDeliveryOrderImageMapper flashDeliveryOrderImageMapper = mock(FlashDeliveryOrderImageMapper.class);
+    private final PushMessageServiceImpl service = new PushMessageServiceImpl();
+
+    @BeforeAll
+    static void initTableInfo() {
+        // 纯单测下初始化 MP 实体元数据,否则 LambdaWrapper 解析列名时无缓存可用
+        MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(assistant, PosOrder.class);
+        TableInfoHelper.initTableInfo(assistant, FlashDeliveryOrder.class);
+        TableInfoHelper.initTableInfo(assistant, FlashDeliveryOrderImage.class);
+    }
+
+    @BeforeEach
+    void setUp() {
+        ReflectionTestUtils.setField(service, "posOrderMapper", posOrderMapper);
+        ReflectionTestUtils.setField(service, "flashDeliveryOrderMapper", flashDeliveryOrderMapper);
+        ReflectionTestUtils.setField(service, "flashDeliveryOrderImageMapper", flashDeliveryOrderImageMapper);
+    }
+
+    @Test
+    void foodOrderMessageGetsShopNameAndDeliveryAddress() {
+        PushMessage message = message("991790045286244", 1);
+        PosOrder order = new PosOrder();
+        order.setDdId("991790045286244");
+        order.setPosName("阿寶早餐店");
+        order.setShAddress("台北市大安區仁愛路一段1號");
+        when(posOrderMapper.selectList(any(Wrapper.class))).thenReturn(List.of(order));
+
+        service.attachOrderInfo(List.of(message));
+
+        assertEquals("阿寶早餐店", message.getShopName());
+        assertEquals("台北市大安區仁愛路一段1號", message.getShAddress());
+        verify(flashDeliveryOrderMapper, never()).selectList(any(Wrapper.class));
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Wrapper<PosOrder>> captor = ArgumentCaptor.forClass(Wrapper.class);
+        verify(posOrderMapper).selectList(captor.capture());
+        // 反查必须按订单号过滤且不逐条查询(in 参数由 MP 惰性注册,断言以 sqlSegment 为准)
+        LambdaQueryWrapper<PosOrder> queryWrapper = (LambdaQueryWrapper<PosOrder>) captor.getValue();
+        String sqlSegment = queryWrapper.getSqlSegment();
+        assertTrue(sqlSegment.contains("dd_id"));
+        assertTrue(sqlSegment.contains("IN"));
+    }
+
+    @Test
+    void flashOrderMessageGetsPickupAndDeliveryAddress() {
+        PushMessage message = message("F20260924001", 3);
+        FlashDeliveryOrder order = new FlashDeliveryOrder();
+        order.setOrderNo("F20260924001");
+        order.setPickupAddress("台中市西屯區文華路100號");
+        order.setPickupAddressDetail("3樓之2");
+        order.setDeliveryAddress("台中市北區三民路三段129號");
+        order.setDeliveryAddressDetail("B1");
+        when(flashDeliveryOrderMapper.selectList(any(Wrapper.class))).thenReturn(List.of(order));
+
+        service.attachOrderInfo(List.of(message));
+
+        assertEquals("台中市西屯區文華路100號", message.getPickupAddress());
+        assertEquals("3樓之2", message.getPickupAddressDetail());
+        assertEquals("台中市北區三民路三段129號", message.getDeliveryAddress());
+        assertEquals("B1", message.getDeliveryAddressDetail());
+        verify(posOrderMapper, never()).selectList(any(Wrapper.class));
+    }
+
+    @Test
+    void foodOrderMessageBackfillsFirstFoodImage() {
+        PushMessage message = message("991790045286244", 1);
+        PosOrder order = new PosOrder();
+        order.setDdId("991790045286244");
+        order.setPosName("阿寶早餐店");
+        // food 快照行结构(App 下单传入):id/name/image/price/otherPrice/number/ask/beizhu/addType
+        order.setFood("[{\"id\":9079,\"name\":\"草莓冰淇淋\",\"image\":\"/profile/upload/2026/08/18/a.png\",\"price\":55,\"otherPrice\":20,\"number\":1},"
+                + "{\"id\":9080,\"name\":\"蛋餅\",\"image\":\"/profile/upload/2026/08/18/b.png\",\"price\":40,\"otherPrice\":0,\"number\":2}]");
+        when(posOrderMapper.selectList(any(Wrapper.class))).thenReturn(List.of(order));
+
+        service.attachOrderInfo(List.of(message));
+
+        // 只取第一行商品的 image
+        assertEquals("/profile/upload/2026/08/18/a.png", message.getOrderImage());
+    }
+
+    @Test
+    void foodOrderWithoutUsableFoodJsonLeavesOrderImageNull() {
+        PushMessage message = message("991790045286244", 1);
+        PosOrder order = new PosOrder();
+        order.setDdId("991790045286244");
+        order.setFood("not-a-json");
+        when(posOrderMapper.selectList(any(Wrapper.class))).thenReturn(List.of(order));
+
+        service.attachOrderInfo(List.of(message));
+
+        assertNull(message.getOrderImage());
+    }
+
+    @Test
+    void flashOrderMessageBackfillsFirstSenderImage() {
+        PushMessage message = message("F20260924001", 3);
+        FlashDeliveryOrder order = new FlashDeliveryOrder();
+        order.setId(77L);
+        order.setOrderNo("F20260924001");
+        order.setPickupAddress("台中市西屯區文華路100號");
+        when(flashDeliveryOrderMapper.selectList(any(Wrapper.class))).thenReturn(List.of(order));
+        when(flashDeliveryOrderImageMapper.selectList(any(Wrapper.class))).thenReturn(List.of(
+                senderImage(77L, 1, "/profile/upload/2026/09/24/second.png"),
+                senderImage(77L, 0, "/profile/upload/2026/09/24/first.png"),
+                proofImage(77L, "PICKUP", "/profile/upload/2026/09/24/pickup.png")));
+
+        service.attachOrderInfo(List.of(message));
+
+        // 寄件凭证按 sortOrder 取第一张;取件/送达凭证不作为缩略图
+        assertEquals("/profile/upload/2026/09/24/first.png", message.getOrderImage());
+    }
+
+    @Test
+    void messageWithoutDdIdStaysUntouched() {
+        PushMessage message = message(null, 1);
+
+        service.attachOrderInfo(List.of(message));
+
+        verify(posOrderMapper, never()).selectList(any(Wrapper.class));
+        verify(flashDeliveryOrderMapper, never()).selectList(any(Wrapper.class));
+        assertNull(message.getShopName());
+    }
+
+    @Test
+    void nonOrderPushTypeStaysUntouched() {
+        PushMessage message = message("R20260924001", 2);
+
+        service.attachOrderInfo(List.of(message));
+
+        verify(posOrderMapper, never()).selectList(any(Wrapper.class));
+        verify(flashDeliveryOrderMapper, never()).selectList(any(Wrapper.class));
+        assertNull(message.getShopName());
+    }
+
+    @Test
+    void missingOrderLeavesMessageUntouched() {
+        PushMessage message = message("991790045286244", 1);
+        when(posOrderMapper.selectList(any(Wrapper.class))).thenReturn(Collections.emptyList());
+
+        service.attachOrderInfo(List.of(message));
+
+        assertNull(message.getShopName());
+        assertNull(message.getShAddress());
+    }
+
+    @Test
+    void emptyListTouchesNothing() {
+        service.attachOrderInfo(Collections.emptyList());
+
+        verify(posOrderMapper, never()).selectList(any(Wrapper.class));
+        verify(flashDeliveryOrderMapper, never()).selectList(any(Wrapper.class));
+    }
+
+    private PushMessage message(String ddId, Integer pushType) {
+        PushMessage message = new PushMessage();
+        message.setDdId(ddId);
+        message.setPushType(pushType);
+        return message;
+    }
+
+    private FlashDeliveryOrderImage senderImage(Long orderId, int sortOrder, String url) {
+        return proofImage(orderId, "SENDER", url, sortOrder);
+    }
+
+    private FlashDeliveryOrderImage proofImage(Long orderId, String proofType, String url) {
+        return proofImage(orderId, proofType, url, 0);
+    }
+
+    private FlashDeliveryOrderImage proofImage(Long orderId, String proofType, String url, int sortOrder) {
+        FlashDeliveryOrderImage image = new FlashDeliveryOrderImage();
+        image.setOrderId(orderId);
+        image.setProofType(proofType);
+        image.setImageUrl(url);
+        image.setSortOrder(sortOrder);
+        return image;
+    }
+}

+ 6 - 0
updatesql/sql.md

@@ -1772,3 +1772,9 @@ ALTER TABLE push_message ADD COLUMN is_read TINYINT NOT NULL DEFAULT 0 COMMENT '
 -- 历史消息统一置为已读,避免上线当天全员角标爆量
 UPDATE push_message SET is_read = 1;
 ```
+
+```sql
+-- 2026-09-24 消息列表反查订单展示信息(Bug #671 骑手消息显示商家名+地址/闪送取收件地址)
+ALTER TABLE push_message ADD COLUMN dd_id VARCHAR(32) DEFAULT NULL COMMENT '关联订单号(外卖ddId/闪送orderNo)';
+ALTER TABLE push_message ADD COLUMN push_type TINYINT DEFAULT NULL COMMENT '推送业务类型 1外卖 2充值 3闪送';
+```