Browse Source

新增订单群聊标识保存接口并完善详情和骑手列表返回

qmj 1 day ago
parent
commit
93750f8397
29 changed files with 378 additions and 1 deletions
  1. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutQueryService.java
  2. 28 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java
  3. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderQsOprateController.java
  4. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreCardView.java
  5. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreMemberView.java
  6. 5 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/OrderImGroupRequest.java
  7. 1 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  8. 1 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  9. 1 0
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  10. 1 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  11. 1 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  12. 1 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  13. 5 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutQueryServiceTest.java
  14. 146 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderImGroupControllerTest.java
  15. 25 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderQsOprateControllerTest.java
  16. 4 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrder.java
  17. 5 1
      ruoyi-system/src/main/resources/mapper/system/PosOrderMapper.xml
  18. 53 0
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/PosOrderImGroupMapperTest.java
  19. 34 0
      specs/012-im-user-integration/contracts/order-im-group.md
  20. 4 0
      specs/012-im-user-integration/data-model.md
  21. 7 0
      specs/012-im-user-integration/plan.md
  22. 4 0
      specs/012-im-user-integration/quickstart.md
  23. 8 0
      specs/012-im-user-integration/spec.md
  24. 9 0
      specs/012-im-user-integration/tasks.md
  25. 6 0
      specs/036-multi-store-checkout/frontend-app-integration.md
  26. 4 0
      specs/036-multi-store-checkout/plan.md
  27. 4 0
      specs/036-multi-store-checkout/spec.md
  28. 4 0
      specs/036-multi-store-checkout/tasks.md
  29. 9 0
      updatesql/sql.md

+ 2 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutQueryService.java

@@ -151,6 +151,7 @@ public class MultiStoreCheckoutQueryService {
                 if ("ORDER".equals(view.getCardType())) {
                     attachOrderContext(view.getOrderId(), originals, storeMap, userMap, longitude, latitude,
                             view::setLegacyOrder, view::setStore, view::setUser, view::setJuli, view::setGoodsCount);
+                    view.setImGroupId(view.getLegacyOrder() == null ? null : view.getLegacyOrder().getImGroupId());
                 } else {
                     int cardGoods = 0;
                     boolean anyGoods = false;
@@ -158,6 +159,7 @@ public class MultiStoreCheckoutQueryService {
                         attachOrderContext(member.getOrderId(), originals, storeMap, userMap, longitude, latitude,
                                 member::setLegacyOrder, member::setStore, member::setUser, member::setJuli,
                                 member::setGoodsCount);
+                        member.setImGroupId(member.getLegacyOrder() == null ? null : member.getLegacyOrder().getImGroupId());
                         // 组没有单一配送里程;逐子单暴露旧详情的 jvli,缺失快照不臆造距离。
                         member.setJvli(member.getLegacyOrder() == null ? null : member.getLegacyOrder().getJvli());
                         if (member.getGoodsCount() != null) {

+ 28 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java

@@ -13,6 +13,7 @@ import com.fasterxml.jackson.databind.SerializationFeature;
 import com.ruoyi.app.order.dto.OrderPositionInfo;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
 import com.ruoyi.app.order.dto.PositionDto;
+import com.ruoyi.app.order.dto.OrderImGroupRequest;
 import com.ruoyi.app.order.dto.QsDto;
 import com.ruoyi.app.order.dto.AdminOrderActionRequest;
 import com.ruoyi.app.order.dto.AdminOrderStatusContext;
@@ -675,6 +676,7 @@ public class PosOrderController extends BaseController {
         JSONObject org = JSON.parseObject(
             JSON.toJSONStringWithDateFormat(orlist, "yyyy-MM-dd HH:mm:ss")
         );
+        org.put("imGroupId", orlist.getImGroupId());
         // 以下为非PosOrder字段或需要特殊处理的字段
         org.put("shanghu", infoUserService.getById(orlist.getShId()));
         org.put("store", posStoreService.getById(orlist.getMdId()));
@@ -745,6 +747,7 @@ public class PosOrderController extends BaseController {
         JSONObject org = JSON.parseObject(
             JSON.toJSONStringWithDateFormat(orlist, "yyyy-MM-dd HH:mm:ss")
         );
+        org.put("imGroupId", orlist.getImGroupId());
         // 以下为非PosOrder字段或需要特殊处理的字段
         org.put("shanghu", infoUserService.getById(orlist.getShId()));
         org.put("store", posStoreService.getById(orlist.getMdId()));
@@ -765,6 +768,29 @@ public class PosOrderController extends BaseController {
         return success(org);
     }
 
+    /** 保存群聊关系只修改这一列,不经废弃的整单编辑接口,避免覆盖订单履约或支付状态。 */
+    @Anonymous
+    @Auth
+    @PostMapping("/imGroup")
+    public AjaxResult saveImGroup(@RequestHeader String token, @RequestBody OrderImGroupRequest input) {
+        if (input == null || input.ddId() == null || input.ddId().isBlank()
+                || input.imGroupId() == null || input.imGroupId().isBlank()
+                || input.imGroupId().codePointCount(0, input.imGroupId().length()) > 200) {
+            throw new ServiceException(MessageUtils.message("order.im.group.invalid"));
+        }
+        PosOrder order = posOrderService.getOne(new LambdaQueryWrapper<PosOrder>()
+                .eq(PosOrder::getDdId, input.ddId()));
+        // 同一订单的用户、当前接单骑手和有门店权限的商家共用详情访问规则。
+        requireOrderDetailAccess(order);
+        if (Objects.equals(order.getImGroupId(), input.imGroupId())) {
+            return success();
+        }
+        PosOrder patch = new PosOrder();
+        patch.setId(order.getId());
+        patch.setImGroupId(input.imGroupId());
+        return toAjax(posOrderService.updatePosOrder(patch));
+    }
+
     private void requireOrderDetailAccess(PosOrder order) {
         Long loginUserId = AuthContext.requireUserId();
         InfoUser actor = infoUserService.getById(loginUserId);
@@ -863,6 +889,7 @@ public class PosOrderController extends BaseController {
             org.put("user", userMap.get(order.getUserId()));
             org.put("jvli", jl.format(order.getJvli()));
             org.put("juli", jl.format(order.getJuli()));
+            org.put("imGroupId", order.getImGroupId());
             org.put("food", JSONArray.parseArray(order.getFood()));
             arr.add(org);
         }
@@ -904,6 +931,7 @@ public class PosOrderController extends BaseController {
             org.put("qishou", userMap.get(order.getQsId()));
             org.put("jvli", jl.format(order.getJvli()));
             org.put("qsjvli", jl.format(order.getJuli()));
+            org.put("imGroupId", order.getImGroupId());
             org.put("food", JSONArray.parseArray(order.getFood()));
             org.put("sqImg", org.remove("qsImg"));
             arr.add(org);

+ 2 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderQsOprateController.java

@@ -426,6 +426,7 @@ public class PosOrderQsOprateController extends BaseController {
                     JSON.toJSONStringWithDateFormat(order, "yyyy-MM-dd HH:mm:ss")
             );
             org.put("juli",null);
+            org.put("imGroupId", order.getImGroupId());
             // 计算距离(km)
             if (order.getLongitude() != null && order.getLatitude() != null && longitude != null && latitude != null) {
                 double lat1 = Math.toRadians(order.getLatitude().doubleValue());
@@ -577,6 +578,7 @@ public class PosOrderQsOprateController extends BaseController {
                 JSON.toJSONStringWithDateFormat(order, "yyyy-MM-dd HH:mm:ss")
         );
         org.put("juli", null);
+        org.put("imGroupId", order.getImGroupId());
         org.put("store", order.getMdId() == null ? null : posStoreService.getById(order.getMdId().intValue()));
         org.put("user", order.getUserId() == null ? null : infoUserService.getById(order.getUserId()));
         return success(org);

+ 2 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreCardView.java

@@ -16,6 +16,8 @@ public class MultiStoreCardView {
     private Long orderId;
     /** 普通单沿用原骑手/用户列表的完整订单字段,不把旧单强行压缩为 036 子单摘要。 */
     private PosOrder legacyOrder;
+    /** 普通单的 IM 群聊标识;多店组的群聊按 members 中的真实子单分别返回。 */
+    private String imGroupId;
     /** 普通单卡与旧骑手列表逐项对齐的门店、用户与直线距离;组卡/批次卡无单一对应,保持空。 */
     private PosStore store;
     private InfoUser user;

+ 2 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreMemberView.java

@@ -26,6 +26,8 @@ public class MultiStoreMemberView {
     private Integer codToMerchantAmount;
     /** 与旧骑手列表逐单展示对齐的附加信息;无对应数据时保持空。 */
     private PosOrder legacyOrder;
+    /** 本子单对应的 IM 群聊标识,不能与配送组 groupId 混用。 */
+    private String imGroupId;
     private PosStore store;
     private InfoUser user;
     private Double juli;

+ 5 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/OrderImGroupRequest.java

@@ -0,0 +1,5 @@
+package com.ruoyi.app.order.dto;
+
+/** App 完成 IM 建群后,按真实订单号保存群聊标识。 */
+public record OrderImGroupRequest(String ddId, String imGroupId) {
+}

+ 1 - 0
ruoyi-admin/src/main/resources/i18n/messages.properties

@@ -502,3 +502,4 @@ promotion.freeDelivery.duplicateOrder=订单已提交,请勿重复下单
 native.push.test.invalid=原生推送测试参数不完整或不正确
 native.push.test.accepted=推送服务已接受消息,请检查手机通知
 native.push.test.failed=原生推送发送失败
+order.im.group.invalid=订单号和群聊标识不能为空,群聊标识不能超过200个字符

+ 1 - 0
ruoyi-admin/src/main/resources/i18n/messages_en_US.properties

@@ -488,3 +488,4 @@ promotion.freeDelivery.duplicateOrder=The order has already been submitted, plea
 native.push.test.invalid=Invalid or incomplete native push test parameters
 native.push.test.accepted=The push provider accepted the message; check the phone notification
 native.push.test.failed=Native push delivery failed
+order.im.group.invalid=Order number and chat group ID are required; the chat group ID must not exceed 200 characters

+ 1 - 0
ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties

@@ -489,3 +489,4 @@ promotion.freeDelivery.duplicateOrder=คำสั่งซื้อถูกส
 native.push.test.invalid=ข้อมูลทดสอบการแจ้งเตือนแบบเนทีฟไม่ครบถ้วนหรือไม่ถูกต้อง
 native.push.test.accepted=ผู้ให้บริการรับข้อความแล้ว โปรดตรวจสอบการแจ้งเตือนบนโทรศัพท์
 native.push.test.failed=ส่งการแจ้งเตือนแบบเนทีฟไม่สำเร็จ
+order.im.group.invalid=ต้องระบุหมายเลขคำสั่งซื้อและรหัสกลุ่มแชต โดยรหัสกลุ่มแชตต้องไม่เกิน 200 ตัวอักษร

+ 1 - 0
ruoyi-admin/src/main/resources/i18n/messages_vi.properties

@@ -26,3 +26,4 @@ multi.store.checkout.excluded.outside.fence=ngoài phạm vi giao hàng tính t
 multi.store.checkout.excluded.no.payment=không có phương thức thanh toán khả dụng
 multi.store.checkout.payment.dispatched=Cửa hàng này đã gửi yêu cầu thanh toán {0} nên không thể đổi phương thức thanh toán. Vui lòng quay lại hoàn tất thanh toán, hoặc hủy giao dịch nhiều cửa hàng này và đặt lại.
 multi.store.checkout.invoice.unavailable=Các cửa hàng tham gia chưa hỗ trợ hóa đơn điện tử
+order.im.group.invalid=Mã đơn hàng và mã nhóm trò chuyện không được để trống; mã nhóm không được vượt quá 200 ký tự

+ 1 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties

@@ -490,3 +490,4 @@ promotion.freeDelivery.duplicateOrder=订单已提交,请勿重复下单
 native.push.test.invalid=原生推送测试参数不完整或不正确
 native.push.test.accepted=推送服务已接受消息,请检查手机通知
 native.push.test.failed=原生推送发送失败
+order.im.group.invalid=订单号和群聊标识不能为空,群聊标识不能超过200个字符

+ 1 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties

@@ -489,3 +489,4 @@ promotion.freeDelivery.duplicateOrder=訂單已提交,請勿重複下單
 native.push.test.invalid=原生推播測試參數不完整或不正確
 native.push.test.accepted=推播服務已接受訊息,請檢查手機通知
 native.push.test.failed=原生推播傳送失敗
+order.im.group.invalid=訂單編號和群聊識別碼不可空白,群聊識別碼不可超過200個字元

+ 5 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutQueryServiceTest.java

@@ -179,10 +179,12 @@ class MultiStoreCheckoutQueryServiceTest {
         memberOrder.setId(71L); memberOrder.setMdId(5L); memberOrder.setUserId(21L);
         memberOrder.setLongitude(riderLng); memberOrder.setLatitude(riderLat);
         memberOrder.setFood("[{\"name\":\"A\",\"number\":2},{\"name\":\"B\",\"number\":1}]");
+        memberOrder.setImGroupId("member-room");
         PosOrder ordinaryOrder = new PosOrder();
         ordinaryOrder.setId(9L); ordinaryOrder.setMdId(5L); ordinaryOrder.setUserId(21L);
         ordinaryOrder.setLongitude(riderLng); ordinaryOrder.setLatitude(riderLat);
         ordinaryOrder.setFood("[{\"name\":\"C\",\"number\":4}]");
+        ordinaryOrder.setImGroupId("ordinary-room");
         when(ordinaryOrders.listByIds(List.of(9L, 71L))).thenReturn(List.of(memberOrder, ordinaryOrder));
         PosStore store = new PosStore();
         store.setId(5);
@@ -200,12 +202,15 @@ class MultiStoreCheckoutQueryServiceTest {
         assertEquals(0.0, groupCard.getMembers().get(0).getJuli());
         assertEquals(3, groupCard.getMembers().get(0).getGoodsCount());
         assertEquals(3, groupCard.getGoodsCount());
+        assertEquals("member-room", groupCard.getMembers().get(0).getImGroupId());
+        assertEquals("member-room", groupCard.getMembers().get(0).getLegacyOrder().getImGroupId());
         MultiStoreCardView orderCard = result.records().get(1);
         assertEquals(9L, orderCard.getLegacyOrder().getId());
         assertEquals(5, orderCard.getStore().getId());
         assertEquals(21L, orderCard.getUser().getUserId());
         assertEquals(0.0, orderCard.getJuli());
         assertEquals(4, orderCard.getGoodsCount());
+        assertEquals("ordinary-room", orderCard.getImGroupId());
     }
 
     @Test

+ 146 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderImGroupControllerTest.java

@@ -0,0 +1,146 @@
+package com.ruoyi.app.order;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.ruoyi.app.order.dto.OrderImGroupRequest;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.service.MerchantStoreAccessService;
+import com.ruoyi.system.utils.AuthContext;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+class OrderImGroupControllerTest {
+    private final PosOrderController controller = new PosOrderController();
+    private final IPosOrderService orders = mock(IPosOrderService.class);
+    private final IInfoUserService users = mock(IInfoUserService.class);
+    private final MerchantStoreAccessService merchants = mock(MerchantStoreAccessService.class);
+
+    OrderImGroupControllerTest() {
+        ReflectionTestUtils.setField(controller, "posOrderService", orders);
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        ReflectionTestUtils.setField(controller, "merchantStoreAccessService", merchants);
+        ReflectionTestUtils.setField(controller, "linePayOrderGuard", new com.ruoyi.app.pay.LinePayOrderGuard(
+                mock(com.ruoyi.system.service.IPosOrderLinePaymentService.class)));
+    }
+
+    @Test
+    void orderUserRiderAndAuthorizedMerchantCanSaveOnlyGroupField() {
+        try (var auth = mockStatic(AuthContext.class); var messages = mockStatic(MessageUtils.class)) {
+            PosOrder order = order();
+            when(orders.getOne(any(Wrapper.class))).thenReturn(order);
+            when(orders.updatePosOrder(any(PosOrder.class))).thenReturn(1);
+            for (String type : new String[]{"0", "2", "1", "3", "4", "5"}) {
+                long actorId = "0".equals(type) ? 21L : "2".equals(type) ? 33L : 11L;
+                auth.when(AuthContext::requireUserId).thenReturn(actorId);
+                InfoUser actor = new InfoUser();
+                actor.setUserId(actorId);
+                actor.setUserType(type);
+                actor.setStoreId(5L);
+                when(users.getById(actorId)).thenReturn(actor);
+                AjaxResult result = controller.saveImGroup("token", new OrderImGroupRequest("ORDER-1", "im-room"));
+                assertEquals(200, result.get("code"));
+            }
+            ArgumentCaptor<PosOrder> update = ArgumentCaptor.forClass(PosOrder.class);
+            verify(orders, times(6)).updatePosOrder(update.capture());
+            for (PosOrder patch : update.getAllValues()) {
+                assertEquals(7L, patch.getId());
+                assertEquals("im-room", patch.getImGroupId());
+                assertNull(patch.getState());
+                assertNull(patch.getQsId());
+                assertNull(patch.getDdId());
+            }
+            verify(merchants, times(2)).requireOrderAccess(11L, 7L);
+        }
+    }
+
+    @Test
+    void unrelatedUserAndRiderCannotChangeAnotherOrdersGroup() {
+        try (var auth = mockStatic(AuthContext.class); var messages = mockStatic(MessageUtils.class)) {
+            auth.when(AuthContext::requireUserId).thenReturn(99L);
+            when(orders.getOne(any(Wrapper.class))).thenReturn(order());
+            for (String type : new String[]{"0", "2"}) {
+                InfoUser actor = new InfoUser(); actor.setUserId(99L); actor.setUserType(type);
+                when(users.getById(99L)).thenReturn(actor);
+                assertThrows(ServiceException.class,
+                        () -> controller.saveImGroup("token", new OrderImGroupRequest("ORDER-1", "im-room")));
+            }
+            verify(orders, never()).updatePosOrder(any());
+        }
+    }
+
+    @Test
+    void invalidAndOverlengthGroupAreRejectedBeforeQuery() {
+        try (var messages = mockStatic(MessageUtils.class)) {
+            for (OrderImGroupRequest input : new OrderImGroupRequest[]{null,
+                    new OrderImGroupRequest("", "room"), new OrderImGroupRequest("ORDER-1", " "),
+                    new OrderImGroupRequest("ORDER-1", "x".repeat(201))}) {
+                assertThrows(ServiceException.class, () -> controller.saveImGroup("token", input));
+            }
+            verifyNoInteractions(orders);
+        }
+    }
+
+    @Test
+    void acceptsTwoHundredCharactersAndRepeatedValueIsIdempotent() {
+        try (var auth = mockStatic(AuthContext.class); var messages = mockStatic(MessageUtils.class)) {
+            auth.when(AuthContext::requireUserId).thenReturn(21L);
+            InfoUser actor = new InfoUser(); actor.setUserId(21L); actor.setUserType("0");
+            when(users.getById(21L)).thenReturn(actor);
+            PosOrder order = order();
+            String groupId = "群".repeat(200);
+            when(orders.getOne(any(Wrapper.class))).thenReturn(order);
+            when(orders.updatePosOrder(any())).thenReturn(1);
+            assertEquals(200, controller.saveImGroup("token", new OrderImGroupRequest("ORDER-1", groupId)).get("code"));
+            order.setImGroupId(groupId);
+            assertEquals(200, controller.saveImGroup("token", new OrderImGroupRequest("ORDER-1", groupId)).get("code"));
+            verify(orders, times(1)).updatePosOrder(any());
+        }
+    }
+
+    private PosOrder order() {
+        PosOrder order = new PosOrder();
+        order.setId(7L); order.setDdId("ORDER-1"); order.setUserId(21L);
+        order.setQsId(33L); order.setShId(11L); order.setMdId(5L);
+        return order;
+    }
+
+    @Test
+    void sharedOrderDetailReturnsGroupIdentifier() {
+        try (var auth = mockStatic(AuthContext.class); var messages = mockStatic(MessageUtils.class)) {
+            auth.when(AuthContext::requireUserId).thenReturn(21L);
+            PosOrder order = order(); order.setImGroupId("detail-room");
+            order.setQsId(null); order.setFood("[]"); order.setShAddress("{}");
+            when(orders.getOne(any(Wrapper.class))).thenReturn(order);
+            InfoUser user = new InfoUser(); user.setUserId(21L); user.setUserType("0");
+            when(users.getById(21L)).thenReturn(user);
+            ReflectionTestUtils.setField(controller, "posStoreService",
+                    mock(com.ruoyi.system.service.IPosStoreService.class));
+            var ratings = mock(com.ruoyi.system.service.IPosOrderRatingService.class);
+            var ratingMapper = mock(com.ruoyi.system.mapper.PosOrderRatingMapper.class);
+            when(ratings.getBaseMapper()).thenReturn(ratingMapper);
+            when(ratingMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
+            ReflectionTestUtils.setField(controller, "posOrderRatingService", ratings);
+            var promotions = mock(com.ruoyi.system.mapper.PosOrderPromotionMapper.class);
+            when(promotions.selectByOrderId(7L)).thenReturn(java.util.List.of());
+            ReflectionTestUtils.setField(controller, "posOrderPromotionMapper", promotions);
+
+            var data = (com.alibaba.fastjson.JSONObject) controller.getorderxq("token", "ORDER-1").get("data");
+
+            assertEquals("detail-room", data.getString("imGroupId"));
+            order.setImGroupId(null);
+            var unbound = (com.alibaba.fastjson.JSONObject) controller.getorderxq("token", "ORDER-1").get("data");
+            assertTrue(unbound.containsKey("imGroupId"));
+            assertNull(unbound.get("imGroupId"));
+        }
+    }
+}

+ 25 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderQsOprateControllerTest.java

@@ -97,6 +97,31 @@ class PosOrderQsOprateControllerTest {
         assertTrue(wrapper.getParamNameValuePairs().containsValue(1L));
     }
 
+    @Test
+    void riderOrderListAndDetailReturnImGroupId() {
+        PosOrderQsOprateController controller = new PosOrderQsOprateController();
+        IPosOrderService orders = mock(IPosOrderService.class);
+        IPosStoreService stores = mock(IPosStoreService.class);
+        IInfoUserService users = mock(IInfoUserService.class);
+        ReflectionTestUtils.setField(controller, "posOrderService", orders);
+        ReflectionTestUtils.setField(controller, "posStoreService", stores);
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        PosOrder order = new PosOrder(); order.setId(7L); order.setDdId("ORDER-1");
+        order.setMdId(5L); order.setUserId(21L); order.setQsId(88L); order.setImGroupId("rider-room");
+        Page<PosOrder> page = new Page<>(1, 10); page.setRecords(List.of(order));
+        when(orders.page(any(Page.class), any(Wrapper.class))).thenReturn(page);
+        when(orders.getOne(any(Wrapper.class))).thenReturn(order);
+        when(stores.listByIds(any())).thenReturn(List.of());
+        when(users.listByIds(any())).thenReturn(List.of());
+        String token = JwtUtil.token("88", "rider");
+
+        JSONObject list = (JSONObject) controller.orderList(token, 1, 10, "toPickup", null, null).get("data");
+        JSONObject detail = (JSONObject) controller.getOrderDetial(token, "ORDER-1").get("data");
+
+        assertEquals("rider-room", list.getJSONArray("records").getJSONObject(0).getString("imGroupId"));
+        assertEquals("rider-room", detail.getString("imGroupId"));
+    }
+
     @Test
     void acceptOrderRejectsSelfDeliveryOrder() {
         PosOrderQsOprateController controller = new PosOrderQsOprateController();

+ 4 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrder.java

@@ -36,6 +36,10 @@ public class PosOrder {
      */
     private String ddId;
 
+    /** IM 订单群聊标识,由 App 创建群聊后回写;每张真实订单独立保存,最长 200 字符。 */
+    @TableField("im_group_id")
+    private String imGroupId;
+
     /**
      * 商家id
      */

+ 5 - 1
ruoyi-system/src/main/resources/mapper/system/PosOrderMapper.xml

@@ -7,6 +7,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     <resultMap type="PosOrder" id="PosOrderResult">
         <result property="id"    column="id"    />
         <result property="ddId"    column="dd_id"    />
+        <result property="imGroupId" column="im_group_id" />
         <result property="shId"    column="sh_id"    />
         <result property="mdId"    column="md_id"    />
         <result property="cretim"    column="cretim"    />
@@ -68,7 +69,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     </resultMap>
 
     <sql id="selectPosOrderVo">
-        select id, dd_id, sh_id, md_id, cretim, shdz_id, user_id,sh_address, amount, remarks, state, type, delry_time,food,yh_id,yh_name,md_yh_id,md_yh_name,md_discount_amount,jvli,freight,dining_status,qs_id,pay_url,collect_payment,activity,md_activity,kefu_state,kefu_content,kefu_repeat,repeat_dd_id,sales_name,md_sales_name,sales_reduction,md_sales_reduction,points,points_reduction,sd_time,pay_type,order_source,parent_dd_id,order_category,table_id,table_no,logo,pos_name,food_amount,pick_up_num,pick_up_time,reserve_phone,is_accepted,is_display,display_time,effective_at,delivery_status,pay_status,after_sale_status,promotion_pricing_version,merchant_delivery_subsidy,customer_delivery_fee from pos_order
+        select id, dd_id, im_group_id, sh_id, md_id, cretim, shdz_id, user_id,sh_address, amount, remarks, state, type, delry_time,food,yh_id,yh_name,md_yh_id,md_yh_name,md_discount_amount,jvli,freight,dining_status,qs_id,pay_url,collect_payment,activity,md_activity,kefu_state,kefu_content,kefu_repeat,repeat_dd_id,sales_name,md_sales_name,sales_reduction,md_sales_reduction,points,points_reduction,sd_time,pay_type,order_source,parent_dd_id,order_category,table_id,table_no,logo,pos_name,food_amount,pick_up_num,pick_up_time,reserve_phone,is_accepted,is_display,display_time,effective_at,delivery_status,pay_status,after_sale_status,promotion_pricing_version,merchant_delivery_subsidy,customer_delivery_fee from pos_order
     </sql>
 
     <select id="selectPosOrderList" parameterType="PosOrder" resultMap="PosOrderResult">
@@ -159,6 +160,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         insert into pos_order
         <trim prefix="(" suffix=")" suffixOverrides=",">
             <if test="ddId != null">dd_id,</if>
+            <if test="imGroupId != null">im_group_id,</if>
             <if test="shId != null">sh_id,</if>
             <if test="mdId != null">md_id,</if>
             <if test="cretim != null">cretim,</if>
@@ -207,6 +209,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
          </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="ddId != null">#{ddId},</if>
+            <if test="imGroupId != null">#{imGroupId},</if>
             <if test="shId != null">#{shId},</if>
             <if test="mdId != null">#{mdId},</if>
             <if test="cretim != null">#{cretim},</if>
@@ -259,6 +262,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         update pos_order
         <trim prefix="SET" suffixOverrides=",">
             <if test="ddId != null">dd_id = #{ddId},</if>
+            <if test="imGroupId != null">im_group_id = #{imGroupId},</if>
             <if test="shId != null">sh_id = #{shId},</if>
             <if test="mdId != null">md_id = #{mdId},</if>
             <if test="cretim != null">cretim = #{cretim},</if>

+ 53 - 0
ruoyi-system/src/test/java/com/ruoyi/system/mapper/PosOrderImGroupMapperTest.java

@@ -0,0 +1,53 @@
+package com.ruoyi.system.mapper;
+
+import com.ruoyi.system.domain.PosOrder;
+import org.apache.ibatis.builder.xml.XMLMapperBuilder;
+import org.apache.ibatis.session.Configuration;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class PosOrderImGroupMapperTest {
+    @Test
+    void detailAndListSelectGroupAndResultMapUsesGroupColumn() throws Exception {
+        Configuration configuration = configuration();
+        var mapping = configuration.getResultMap("com.ruoyi.system.mapper.PosOrderMapper.PosOrderResult")
+                .getResultMappings().stream().filter(item -> "imGroupId".equals(item.getProperty())).findFirst();
+        assertTrue(mapping.isPresent());
+        assertEquals("im_group_id", mapping.orElseThrow().getColumn());
+        assertTrue(configuration.getMappedStatement("com.ruoyi.system.mapper.PosOrderMapper.selectPosOrderById")
+                .getBoundSql(Map.of("id", 7L)).getSql().contains("im_group_id"));
+    }
+
+    @Test
+    void partialUpdateWritesOnlyGroupForTargetOrder() throws Exception {
+        PosOrder patch = new PosOrder(); patch.setId(7L); patch.setImGroupId("im-room");
+        var bound = configuration().getMappedStatement("com.ruoyi.system.mapper.PosOrderMapper.updatePosOrder")
+                .getBoundSql(patch);
+        assertEquals("update pos_order SET im_group_id = ? where id = ?", bound.getSql().replaceAll("\\s+", " ").trim());
+        assertEquals(java.util.List.of("imGroupId", "id"),
+                bound.getParameterMappings().stream().map(item -> item.getProperty()).toList());
+    }
+
+    @Test
+    void insertPersistsGroupWhenPresent() throws Exception {
+        PosOrder order = new PosOrder(); order.setDdId("ORDER-1"); order.setImGroupId("im-room");
+        var bound = configuration().getMappedStatement("com.ruoyi.system.mapper.PosOrderMapper.insertPosOrder")
+                .getBoundSql(order);
+        assertTrue(bound.getSql().contains("im_group_id"));
+        assertTrue(bound.getParameterMappings().stream().anyMatch(item -> "imGroupId".equals(item.getProperty())));
+    }
+
+    private Configuration configuration() throws Exception {
+        Configuration configuration = new Configuration();
+        configuration.getTypeAliasRegistry().registerAlias("PosOrder", PosOrder.class);
+        String resource = "mapper/system/PosOrderMapper.xml";
+        try (var stream = getClass().getClassLoader().getResourceAsStream(resource)) {
+            assertNotNull(stream);
+            new XMLMapperBuilder(stream, configuration, resource, configuration.getSqlFragments()).parse();
+        }
+        return configuration;
+    }
+}

+ 34 - 0
specs/012-im-user-integration/contracts/order-im-group.md

@@ -0,0 +1,34 @@
+# 订单 IM 群聊标识(2026-10-26)
+
+App/IM 创建群聊、维护商家/骑手/用户成员后,将群标识保存到真实订单。后端不代建群。
+
+## 保存接口
+
+`POST /system/order/imGroup`,请求头 `token: <App 登录 Token>`、`Content-Type: application/json`。
+
+```json
+{
+  "ddId": "176000000000000001",
+  "imGroupId": "im-order-room-001"
+}
+```
+
+- `ddId`:真实子单业务订单号,字符串;多店批次父单号、配送组 ID 不能代替。
+- `imGroupId`:非空字符串,最长 200 字符,原样保存。
+- 仅下单用户、当前接单骑手以及有门店访问权限的商家可保存。方法使用 `@Anonymous` 放行后台 Spring Security,同时由 `@Auth` 校验 App 登录,**不属于免登录接口**。
+- 成功返回 `code=200`。同一标识重复保存成功;传入新值会更新已有标识。无清空操作。订单无群聊时字段为空,历史订单兼容。
+- 接口只修改 `im_group_id`,不修改履约/支付状态,不调用 IM 外部接口。
+
+## 查询返回位置
+
+| 接口 | 群标识位置 |
+| --- | --- |
+| `GET /system/order/getorderxq?ddid=...` | `data.imGroupId` |
+| `GET /system/order/{id}` | `data.imGroupId` |
+| `GET /system/orderQsOprate/getOrderDetial?ddId=...` | `data.imGroupId` |
+| `GET /system/orderQsOprate/orderList` | `data.records[].imGroupId` |
+| `GET /system/order/getqsorderlist`、`/getqishouorderlist` | `data[].imGroupId` |
+| `GET /system/multiStoreCheckout/rider/tasks` 普通单 | `data.records[].imGroupId`、`legacyOrder.imGroupId` |
+| 同接口的多店组、`GET /system/multiStoreCheckout/groups/{groupId}` | `members[].imGroupId`、`members[].legacyOrder.imGroupId` |
+
+多店组没有单一群聊标识,每个真实子单关联自己的群。数据库部署前需人工执行 `updatesql/sql.md` 的本次新增列语句;本地测试不代替实际 IM 建群、邀请成员及 App 打开群聊的联调。

+ 4 - 0
specs/012-im-user-integration/data-model.md

@@ -71,3 +71,7 @@ ALTER TABLE info_user ADD COLUMN im_user_id BIGINT DEFAULT NULL COMMENT 'IM平
 | `imUserId` | String | IM 平台返回的 userId(**String 规避前端 Long 精度丢失**) |
 
 > service 内部 `InfoUser.imUserId` 为 Long;组装 VO 时 `String.valueOf(imUserId)` 转字符串返回。
+
+## 2026-10-26 变更表:pos_order
+
+新增 `im_group_id VARCHAR(200) DEFAULT NULL`,实体/API 属性 `imGroupId`。每张真实订单保存一个当前群聊标识,App/IM 建群后通过保存接口写入;新值可覆盖旧值,历史订单为空。多店父单、配送组不存共享标识,各子单分别保存。XML 与 MyBatis-Plus 查询均映射该列,迁移 SQL 见 `updatesql/sql.md`。

+ 7 - 0
specs/012-im-user-integration/plan.md

@@ -79,3 +79,10 @@ updatesql/sql.md                             # 【改】追加 info_user 加两
 ## Complexity Tracking
 
 无 Constitution 违规,无需记录复杂度豁免。本方案保持最小改动:2 字段、1 接口、1 工具类、1 配置段、1 SQL,不引入新分层、不改动注册流程。
+
+## 2026-10-26 增量计划:订单群聊标识
+
+1. `PosOrder` 增加 `imGroupId`,补齐 `PosOrderMapper.xml` resultMap、公共查询列和动态 insert/update。SQL 写入 `updatesql/sql.md`,不执行迁移。
+2. 在现有 `PosOrderController` 增加 `POST /imGroup`,使用 `OrderImGroupRequest(ddId, imGroupId)`、`@RequestHeader token` 与 `@Auth`,复用 `requireOrderDetailAccess`;只构造含 id/群标识的更新对象,经现有 `IPosOrderService.updatePosOrder` 保存,相同值幂等返回。接口按普通保存语义允许新值覆盖旧值。
+3. 旧列表/详情沿用完整实体返回;新版任务与组详情在普通单卡/子单摘要补 `imGroupId`,不把配送组 ID 当成 IM 群 ID。
+4. 补权限、字段边界、映射与新旧查询回归测试;更新 IM 接口契约及 036 App 接入说明,使用 JDK 21 定向测试和模块构建验证。

+ 4 - 0
specs/012-im-user-integration/quickstart.md

@@ -65,3 +65,7 @@
 - 场景 1~5 全部符合预期。
 - 新老用户(im_api_key 原为空)都能通过同一接口开通(场景 1 即覆盖老用户补建)。
 - 注册流程未被改动(回归验证:注册接口行为与返回结构保持原样)。
+
+## 2026-10-26 订单群聊联调
+
+先人工执行 `updatesql/sql.md` 的 `pos_order.im_group_id` 迁移,再部署后端。App/IM 建群完成后,用该订单用户、当前骑手或有门店权限的商家 Token 调用 [保存群聊标识接口](contracts/order-im-group.md),随后刷新订单详情和骑手列表,确认 `imGroupId` 一致。多店组按 `members[]` 中各真实子单读取,不能用配送组 `groupId` 代替 IM 群标识;历史未绑定单为空。再验证重复保存成功、超过 200 字符及无关账号被拒绝,实际成员邀请和聊天由 App/IM 联调确认。

+ 8 - 0
specs/012-im-user-integration/spec.md

@@ -104,3 +104,11 @@ IM 平台的访问域名与鉴权令牌(extToken)集中存放于系统配置
 - 接口通过项目现有登录鉴权机制识别当前用户(解析 token → userId),不新增鉴权方式。
 - 配置文件采用项目现有 application.yml 风格(参考 ezpay / newebpay 段),新增独立 im 配置段。
 - HTTP 客户端沿用项目现有 org.apache.http 用法(参考 NewebPay 工具类)。
+
+## 2026-10-26 追加:订单群聊标识
+
+- **FR-010**: `pos_order` 增加可空的 `im_group_id VARCHAR(200)`,Java/API 字段为 `imGroupId`。每张真实餐饮订单独立保存,包含多店下单子单;不能与多店配送组 `groupId` 混用。
+- **FR-011**: 提供 `POST /system/order/imGroup`,请求头 `token`,JSON DTO 包含字符串 `ddId` 与 `imGroupId`。复用订单详情权限,只允许订单用户、当前接单骑手和有订单所属门店权限的商家调用;空值或超过 200 字符拒绝。重复相同值成功,传入新值更新该订单标识,仅修改该字段。
+- **FR-012**: 订单详情、旧骑手订单列表/详情返回 `imGroupId`;新版骑手任务的普通单卡返回 `imGroupId`,多店组在 `members[].imGroupId` 及各 `legacyOrder.imGroupId` 返回逐单标识。未绑定时为空。
+- **FR-013**: App/IM 负责建群和成员维护,后端接口仅保存与查询群标识;本次不接入 IM 建群/邀请/解散接口,不修改订单状态。
+- **SC-006**: 覆盖 200 字符边界、非法输入、重复保存及越权拒绝;验证 XML 读写映射、订单详情、骑手新旧列表和多店成员字段透传。

+ 9 - 0
specs/012-im-user-integration/tasks.md

@@ -148,3 +148,12 @@ description: "Task list for 骑手与用户 IM 即时沟通账号接入"
 - `imUserId` 在 VO 中用 String,落库用 Long/BIGINT(防前端 JS 精度丢失 + 容纳 19 位)
 - 复用现有 `JwtUtil.getusid(token)` 解析用户,不新增鉴权方式
 - 不改动注册流程(用户明确要求)
+
+## 2026-10-26 追加:订单群聊标识
+
+- [x] T015 `PosOrder.imGroupId`、XML 查询/新增/修改映射及 `VARCHAR(200)` 手工 SQL。
+- [x] T016 在现有订单 Controller 增加保存接口和 DTO,复用登录及订单访问规则,仅更新群标识。
+- [x] T017 新旧骑手列表、详情和多店逐店摘要返回群标识,补充接口契约与 App 接入说明。
+- [x] T018 验证权限/200 字符边界/幂等与字段读写,运行定向测试和构建;实际数据库迁移与 App/IM 联调由部署后验证。
+
+T018 验证(2026-10-26):JDK 21 定向运行 OrderImGroupControllerTest、PosOrderImGroupMapperTest、PosOrderQsOprateControllerTest、MultiStoreCheckoutQueryServiceTest、MultiStoreRiderRoutesTest、OrderListPaymentFilterTest、MultiStoreCheckoutBatchServiceTest、UserOrderParentDetailMultiStoreTest,共 47 个用例,失败/错误/跳过均为 0;同轮 `mvn -q -pl ruoyi-admin -am -Dtest=上述类 -Dsurefire.failIfNoSpecifiedTests=false package` 打包退出码 0。未执行数据库迁移,未调用实际 IM 建群或验证 App 聊天。

+ 6 - 0
specs/036-multi-store-checkout/frontend-app-integration.md

@@ -656,3 +656,9 @@ msg 文案随 App 语言本地化,下表按简体中文文案识别(按语
 - 升级门禁:旧版两端调 036 接口均收到 UPGRADE_REQUIRED 并提示升级;普通外卖、闪送、自取、堂食现金、夜市自配送、单店转账回归原流程无变化。
 - 所有失败以 `msg` 展示并刷新详情,除 UPGRADE_REQUIRED 外不依赖 errorCode。
 - 联调前确认:036 SQL 已人工执行、平台多店配置已开启、真实支付渠道与推送可达。
+
+## 2026-10-26 订单 IM 群聊标识
+
+每张真实子单新增 `imGroupId`(最长 200 字符)。App/IM 建群后使用 `POST /system/order/imGroup`,请求头传 App `token`,JSON 为 `{ "ddId": "子单orderNo", "imGroupId": "IM群标识" }`。字段、权限与更新规则见 [订单群聊契约](../012-im-user-integration/contracts/order-im-group.md)。
+
+骑手任务普通单卡返回 `imGroupId` 和 `legacyOrder.imGroupId`;组卡与组详情返回 `members[].imGroupId` 和 `members[].legacyOrder.imGroupId`。逐店群聊标识独立,`groupId` 仍是配送组 ID;群创建与成员维护由 App/IM 完成。

+ 4 - 0
specs/036-multi-store-checkout/plan.md

@@ -440,3 +440,7 @@ T033仅修正MultiStoreCheckoutQuoteService的商品可售判断,与单店完
 ## 2026-10-21 组详情逐店距离
 
 复用多店报价已取得的逐店道路米数,在建批重新报价时固化 `pos_order.jvli` 公里快照;组详情 `members[]` 从各子单快照返回 `jvli`,`juli` 与旧普通详情一致为空,不新增地图调用或计算组级单一距离。历史缺失保持空,报价金额及普通订单不变。
+
+## 2026-10-26 IM 群聊字段透传
+
+复用查询服务已经批量加载的 `PosOrder`,普通单卡补 `imGroupId`,多店组成员补各子单的 `imGroupId`,保持 `legacyOrder` 与摘要一致。不新增查询或共享群聊表;保存与 SQL 迁移归入 012 IM 增量规格。回归任务列表和组详情的字段透传。

+ 4 - 0
specs/036-multi-store-checkout/spec.md

@@ -353,3 +353,7 @@
 - **FR-058** 建批请求可沿用普通 `/system/userOrder/createOrder` 的顶层 `invoiceChoice`、`carrierType`、`carrierNum`、`buyerUbn`、`buyerName`、`buyerEmail`、`loveCode`、`saveAsDefault`;整批只选一份发票资料,仅为可开票参与门店的每个子单写 `pos_order_invoice` 意图行,父单和不可开票子单不写。非纸本选择在创建前使用首个可开票参与门店执行既有载具/捐赠/统编校验;全体不可开票时拒绝电子发票选择。纸本与默认抬头沿用普通订单规则,出餐时各子单仍走既有自动开票流程,不在建批时直接开票。
 - **FR-059** 发票资料纳入建批 `requestKey` 的幂等摘要;同键网络重试须保持发票字段一致,不能更改已建子单的开票意图。未传发票字段的旧客户端保持无发票意图的现有行为;不新增数据库列。
 - **SC-043** 混合可开票/不可开票门店仅为前者保存一次意图;全部不可开票且选择电子发票、无效载具或统编均在父子单写入前拒绝;纸本与未选择发票兼容旧建批;同键改发票资料拒绝,普通单原行为不变。
+
+## 2026-10-26 逐单 IM 群聊标识
+
+- **FR-060** 骑手任务普通单卡返回该单 `imGroupId`;多店组卡和组详情返回 `members[].imGroupId`,与各自 `legacyOrder.imGroupId` 一致。字段来自真实子单 `pos_order.im_group_id`,不创建组级共享群聊。保存接口与权限沿用 012 订单群聊契约,配送组 `groupId` 与 IM 群标识互不替代。

+ 4 - 0
specs/036-multi-store-checkout/tasks.md

@@ -178,3 +178,7 @@ T040验证:2026-10-21 定向 23 项(核实器/两服务/选择入口)及 M
 - [x] T044 建批接收与普通订单相同的顶层发票字段,非纸本提交前校验、仅可开票子单捕获意图,默认抬头规则共用,发票字段进入幂等摘要;补混合门店、全不可开票、重试冲突和普通单回归测试,同步 App 契约。无需数据库迁移,不修改 App 源码。
 
 T044验证(2026-10-22):先以无可开票门店的电子发票请求复现旧实现忽略字段、仍建批(定向测试失败);修复后 JDK 21 执行 `mvn -q -pl ruoyi-admin -am verify` 成功,213 份 Surefire 报告合计 1445 项、失败/错误/跳过均 0;`git diff --check` 通过。未连接真实数据库、ezPay 服务或 App 真机,未执行 SQL 迁移。
+
+## 2026-10-26 逐单 IM 群聊标识
+
+- [x] T045 在普通单卡、多店成员摘要中透传 `PosOrder.imGroupId`,补字段一致性测试及 App 接入说明;保存接口和数据库迁移归入 012,真实 IM 建群/聊天仍需 App 联调。

+ 9 - 0
updatesql/sql.md

@@ -2464,3 +2464,12 @@ ALTER TABLE order_delivery_group         CONVERT TO CHARACTER SET utf8mb4 COLLAT
 ALTER TABLE order_checkout_outbox        CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
 ALTER TABLE order_checkout_refund_intent CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
 ```
+
+## 2026-10-26 外卖订单 IM 群聊标识
+
+部署本次代码前手动执行。群聊由 App/IM 创建后回写,每张 `pos_order`(含多店真实子单)独立保存;历史订单为空。不直接执行数据库迁移。
+
+```sql
+ALTER TABLE pos_order
+    ADD COLUMN im_group_id VARCHAR(200) DEFAULT NULL COMMENT '订单IM群聊标识' AFTER dd_id;
+```