Jelajahi Sumber

feat: 新增按门店查询可联系账号接口——GET /merchant/subaccounts/by-store 返回商家主账号+启用分管账号(App 发起 IM 会话/通话用,含昵称/电话/头像/imApiKey/imUserId/cid/deviceToken/voIPToken/cidType 字段,userType 区分主/分管),复用 selectEligibleSubaccountsByStoreId 零新增 SQL,i18n 五语言补 merchant.store.not.found,附 service/controller 单测

qmj 2 minggu lalu
induk
melakukan
7feecf32a4

+ 51 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/MerchantSubaccountApplicationService.java

@@ -6,6 +6,8 @@ import com.ruoyi.app.user.dto.MerchantSubaccountStatusRequest;
 import com.ruoyi.app.user.dto.MerchantSubaccountStoreView;
 import com.ruoyi.app.user.dto.MerchantSubaccountUpdateRequest;
 import com.ruoyi.app.user.dto.MerchantSubaccountView;
+import com.ruoyi.app.user.dto.StoreAccountView;
+import com.ruoyi.app.user.dto.StoreAccountsView;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.system.domain.InfoUser;
@@ -62,6 +64,55 @@ public class MerchantSubaccountApplicationService {
         return listOwnedSubaccounts(ownerUserId);
     }
 
+    /**
+     * 按门店查询可联系账号(商家主账号 + 启用的分管账号),供用户端/骑手端 App 发起 IM 会话/通话。
+     * 与本类其它方法不同:不要求调用方是商家身份,仅按门店维度返回数据。
+     */
+    public StoreAccountsView listStoreAccounts(Long storeId) {
+        PosStore store = storeId == null ? null : posStoreMapper.selectPosStoreById(storeId);
+        if (store == null || !MerchantAccountConstants.NOT_DELETED.equals(store.getDelFlag())) {
+            throw error("merchant.store.not.found");
+        }
+        StoreAccountsView view = new StoreAccountsView();
+        view.setOwner(loadContactableOwner(store.getUserId()));
+        List<StoreAccountView> subaccounts = relationService.selectEligibleSubaccountsByStoreId(storeId)
+                .stream()
+                .map(this::toContactView)
+                .toList();
+        view.setSubaccounts(subaccounts);
+        return view;
+    }
+
+    private StoreAccountView loadContactableOwner(Long ownerUserId) {
+        if (ownerUserId == null) {
+            return null;
+        }
+        InfoUser owner = infoUserService.selectInfoUserByUserId(ownerUserId);
+        if (owner == null
+                || !MerchantAccountConstants.OWNER_USER_TYPE.equals(owner.getUserType())
+                || !MerchantAccountConstants.STATUS_ENABLED.equals(owner.getStatus())
+                || !MerchantAccountConstants.NOT_DELETED.equals(owner.getDelFlag())) {
+            return null;
+        }
+        return toContactView(owner);
+    }
+
+    private StoreAccountView toContactView(InfoUser user) {
+        StoreAccountView account = new StoreAccountView();
+        account.setUserId(user.getUserId());
+        account.setNickName(firstNonBlank(user.getNickName(), user.getFullName(), user.getUserName()));
+        account.setPhone(user.getTelPhone());
+        account.setAvatar(user.getAvatar());
+        account.setImApiKey(user.getImApiKey());
+        account.setImUserId(user.getImUserId() == null ? null : String.valueOf(user.getImUserId()));
+        account.setCid(user.getCid());
+        account.setCidType(user.getCidType());
+        account.setDeviceToken(user.getDeviceToken());
+        account.setVoIPToken(user.getVoIPToken());
+        account.setUserType(user.getUserType());
+        return account;
+    }
+
     @Transactional(rollbackFor = Exception.class)
     public MerchantSubaccountView create(Long ownerUserId, MerchantSubaccountCreateRequest request) {
         accessService.requireOwner(ownerUserId);

+ 12 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/MerchantSubaccountController.java

@@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.PutMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestHeader;
 import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
 @RestController
@@ -35,6 +36,17 @@ public class MerchantSubaccountController extends BaseController {
         return success(applicationService.listForOwner(AuthContext.requireUserId()));
     }
 
+    /**
+     * 按门店查询可联系账号(商家主账号 + 启用的分管账号),供用户端/骑手端 App 发起 IM 会话/通话。
+     * 与本控制器其它接口不同:只要求有效登录 token,不要求商家身份。
+     */
+    @Anonymous
+    @Auth
+    @GetMapping("/by-store")
+    public AjaxResult listByStore(@RequestHeader String token, @RequestParam Long storeId) {
+        return success(applicationService.listStoreAccounts(storeId));
+    }
+
     @Anonymous
     @Auth
     @PostMapping

+ 23 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/StoreAccountView.java

@@ -0,0 +1,23 @@
+package com.ruoyi.app.user.dto;
+
+import lombok.Data;
+
+/**
+ * 门店可联系账号视图(用户端/骑手端 IM 联系、通话用)。
+ */
+@Data
+public class StoreAccountView {
+    private Long userId;
+    private String nickName;
+    private String phone;
+    private String avatar;
+    private String imApiKey;
+    /** IM 平台用户ID,字符串规避前端 Long 精度丢失 */
+    private String imUserId;
+    private String cid;
+    private String cidType;
+    private String deviceToken;
+    private String voIPToken;
+    /** 账号类型:"1" 商家主账号,"5" 商家分管账号 */
+    private String userType;
+}

+ 14 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/StoreAccountsView.java

@@ -0,0 +1,14 @@
+package com.ruoyi.app.user.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 门店账号查询结果:主账号与分管账号分列返回,null 表示无可用主账号。
+ */
+@Data
+public class StoreAccountsView {
+    private StoreAccountView owner;
+    private List<StoreAccountView> subaccounts;
+}

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

@@ -312,6 +312,7 @@ merchant.subaccount.access.denied=無權管理該分管帳號
 merchant.subaccount.create.failed=建立分管帳號失敗
 merchant.subaccount.update.failed=更新分管帳號失敗
 merchant.subaccount.platform.managed=分管帳號只能透過專用入口管理
+merchant.store.not.found=店鋪不存在
 no.user.delivery.type.invalid=配送類型無效,僅支持美食外送或闁送
 no.user.delivery.type.not.enabled=當前賬號未開通美食外送配送
 
@@ -322,6 +323,7 @@ flash.delivery.client.request.id.invalid=客戶端請求號不能為空且不能
 flash.delivery.note.too.long=備註不能超過500個字元
 flash.delivery.service.type.invalid=不支援的閃送服務類型
 flash.delivery.service.unavailable=目前閃送服務暫不可用
+flash.delivery.map.outside.region=地址超出可服務範圍,請確認後重新選擇
 flash.delivery.address.invalid=聯絡人、地址或經緯度資訊無效
 flash.delivery.address.same=取件地址和收件地址不能相同
 flash.delivery.order.not.found=閃送訂單不存在或無權存取

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

@@ -306,6 +306,7 @@ merchant.subaccount.access.denied=You cannot manage this subaccount
 merchant.subaccount.create.failed=Failed to create subaccount
 merchant.subaccount.update.failed=Failed to update subaccount
 merchant.subaccount.platform.managed=Subaccounts can only be managed from the dedicated merchant subaccount entry
+merchant.store.not.found=Store not found
 no.user.delivery.type.invalid=Invalid delivery type; only food delivery or flash delivery is supported
 no.user.delivery.type.not.enabled=This account is not enabled for food delivery orders
 
@@ -316,6 +317,7 @@ flash.delivery.client.request.id.invalid=Client request ID is required and must
 flash.delivery.note.too.long=The note must not exceed 500 characters
 flash.delivery.service.type.invalid=Unsupported flash delivery service type
 flash.delivery.service.unavailable=This flash delivery service is currently unavailable
+flash.delivery.map.outside.region=The address is outside the service area. Please choose another address.
 flash.delivery.address.invalid=Contact, address, or coordinates are invalid
 flash.delivery.address.same=Pickup and delivery locations must be different
 flash.delivery.order.not.found=Flash delivery order does not exist or is not accessible

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

@@ -299,6 +299,7 @@ merchant.subaccount.access.denied=คุณไม่มีสิทธิ์จ
 merchant.subaccount.create.failed=สร้างบัญชีย่อยไม่สำเร็จ
 merchant.subaccount.update.failed=อัปเดตบัญชีย่อยไม่สำเร็จ
 merchant.subaccount.platform.managed=บัญชีย่อยจัดการได้ผ่านช่องทางเฉพาะเท่านั้น
+merchant.store.not.found=ไม่พบร้านค้า
 no.user.delivery.type.invalid=ประเภทการจัดส่งไม่ถูกต้อง รองรับเฉพาะการส่งอาหารหรือส่งด่วน
 no.user.delivery.type.not.enabled=บัญชีนี้ยังไม่ได้เปิดใช้บริการส่งอาหาร
 
@@ -309,6 +310,7 @@ flash.delivery.client.request.id.invalid=ต้องระบุหมายเ
 flash.delivery.note.too.long=หมายเหตุต้องไม่เกิน 500 อักขระ
 flash.delivery.service.type.invalid=ไม่รองรับประเภทบริการส่งด่วนนี้
 flash.delivery.service.unavailable=บริการส่งด่วนนี้ไม่พร้อมใช้งานในขณะนี้
+flash.delivery.map.outside.region=ที่อยู่อยู่นอกเขตพื้นที่ให้บริการ กรุณาเลือกที่อยู่ใหม่
 flash.delivery.address.invalid=ข้อมูลผู้ติดต่อ ที่อยู่ หรือพิกัดไม่ถูกต้อง
 flash.delivery.address.same=ที่อยู่รับและที่อยู่จัดส่งต้องไม่เหมือนกัน
 flash.delivery.order.not.found=ไม่มีคำสั่งซื้อส่งด่วนนี้หรือคุณไม่มีสิทธิ์เข้าถึง

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

@@ -307,6 +307,7 @@ merchant.subaccount.access.denied=无权管理该分管账号
 merchant.subaccount.create.failed=创建分管账号失败
 merchant.subaccount.update.failed=更新分管账号失败
 merchant.subaccount.platform.managed=分管账号只能通过专用入口管理
+merchant.store.not.found=店铺不存在
 no.user.delivery.type.invalid=配送类型无效,仅支持美食外送或快送
 no.user.delivery.type.not.enabled=当前账号未开通美食外送配送
 
@@ -317,6 +318,7 @@ flash.delivery.client.request.id.invalid=客户端请求号不能为空且不能
 flash.delivery.note.too.long=备注不能超过500个字符
 flash.delivery.service.type.invalid=不支持的快送服务类型
 flash.delivery.service.unavailable=当前快送服务暂不可用
+flash.delivery.map.outside.region=地址超出可服务范围,请确认后重新选择
 flash.delivery.address.invalid=联系人、地址或经纬度信息无效
 flash.delivery.address.same=取件地址和收件地址不能相同
 flash.delivery.order.not.found=快送订单不存在或无权访问

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

@@ -307,6 +307,7 @@ merchant.subaccount.access.denied=無權管理該分管帳號
 merchant.subaccount.create.failed=建立分管帳號失敗
 merchant.subaccount.update.failed=更新分管帳號失敗
 merchant.subaccount.platform.managed=分管帳號只能透過專用入口管理
+merchant.store.not.found=店鋪不存在
 no.user.delivery.type.invalid=配送類型無效,僅支持美食外送或闁送
 no.user.delivery.type.not.enabled=當前賬號未開通美食外送配送
 
@@ -317,6 +318,7 @@ flash.delivery.client.request.id.invalid=客戶端請求號不能為空且不能
 flash.delivery.note.too.long=備註不能超過500個字元
 flash.delivery.service.type.invalid=不支援的快送服務類型
 flash.delivery.service.unavailable=目前快送服務暫不可用
+flash.delivery.map.outside.region=地址超出可服務範圍,請確認後重新選擇
 flash.delivery.address.invalid=聯絡人、地址或經緯度資訊無效
 flash.delivery.address.same=取件地址和收件地址不能相同
 flash.delivery.order.not.found=快送訂單不存在或無權存取

+ 111 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/user/MerchantSubaccountApplicationServiceTest.java

@@ -1,22 +1,30 @@
 package com.ruoyi.app.user;
 
 import com.ruoyi.app.user.dto.MerchantSubaccountCreateRequest;
+import com.ruoyi.app.user.dto.StoreAccountsView;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PosStore;
 import com.ruoyi.system.mapper.PosStoreMapper;
 import com.ruoyi.system.service.IInfoUserService;
 import com.ruoyi.system.service.IMerchantSubaccountStoreService;
 import com.ruoyi.system.service.MerchantStoreAccessService;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
 
 import java.util.List;
 import java.util.Set;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -55,4 +63,107 @@ class MerchantSubaccountApplicationServiceTest {
         assertNull(captor.getValue().getPhone());
         verify(businessPhoneService).ensureUnique("0912345678", null);
     }
+
+    @Test
+    void listStoreAccountsRejectsMissingOrDeletedStore() {
+        PosStoreMapper posStoreMapper = mock(PosStoreMapper.class);
+        MerchantSubaccountApplicationService service = newService(posStoreMapper);
+        when(posStoreMapper.selectPosStoreById(7L)).thenReturn(null);
+        PosStore deleted = new PosStore();
+        deleted.setDelFlag("1");
+        when(posStoreMapper.selectPosStoreById(8L)).thenReturn(deleted);
+
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message(anyString()))
+                    .thenAnswer(invocation -> invocation.getArgument(0));
+            ServiceException missing = assertThrows(ServiceException.class,
+                    () -> service.listStoreAccounts(7L));
+            assertEquals("merchant.store.not.found", missing.getMessage());
+            ServiceException deletedStore = assertThrows(ServiceException.class,
+                    () -> service.listStoreAccounts(8L));
+            assertEquals("merchant.store.not.found", deletedStore.getMessage());
+        }
+    }
+
+    @Test
+    void listStoreAccountsReturnsOwnerAndEligibleSubaccountsWithContactFields() {
+        IInfoUserService infoUserService = mock(IInfoUserService.class);
+        IMerchantSubaccountStoreService relationService = mock(IMerchantSubaccountStoreService.class);
+        PosStoreMapper posStoreMapper = mock(PosStoreMapper.class);
+        MerchantSubaccountApplicationService service = newService(infoUserService, relationService, posStoreMapper);
+        PosStore store = new PosStore();
+        store.setDelFlag("0");
+        store.setUserId(10L);
+        when(posStoreMapper.selectPosStoreById(7L)).thenReturn(store);
+        when(infoUserService.selectInfoUserByUserId(10L)).thenReturn(contactUser(10L, "1", "0"));
+        when(relationService.selectEligibleSubaccountsByStoreId(7L))
+                .thenReturn(List.of(contactUser(20L, "5", "0")));
+
+        StoreAccountsView view = service.listStoreAccounts(7L);
+
+        assertEquals(10L, view.getOwner().getUserId());
+        assertEquals("1", view.getOwner().getUserType());
+        assertEquals("nick-10", view.getOwner().getNickName());
+        assertEquals("tel-10", view.getOwner().getPhone());
+        assertEquals("avatar-10", view.getOwner().getAvatar());
+        assertEquals("imKey-10", view.getOwner().getImApiKey());
+        assertEquals("900010", view.getOwner().getImUserId());
+        assertEquals("cid-10", view.getOwner().getCid());
+        assertEquals("ios", view.getOwner().getCidType());
+        assertEquals("device-10", view.getOwner().getDeviceToken());
+        assertEquals("voip-10", view.getOwner().getVoIPToken());
+        assertEquals(1, view.getSubaccounts().size());
+        assertEquals(20L, view.getSubaccounts().get(0).getUserId());
+        assertEquals("5", view.getSubaccounts().get(0).getUserType());
+        assertEquals("nick-20", view.getSubaccounts().get(0).getNickName());
+    }
+
+    @Test
+    void listStoreAccountsOmitsUnavailableOwner() {
+        IInfoUserService infoUserService = mock(IInfoUserService.class);
+        IMerchantSubaccountStoreService relationService = mock(IMerchantSubaccountStoreService.class);
+        PosStoreMapper posStoreMapper = mock(PosStoreMapper.class);
+        MerchantSubaccountApplicationService service = newService(infoUserService, relationService, posStoreMapper);
+        PosStore store = new PosStore();
+        store.setDelFlag("0");
+        store.setUserId(10L);
+        when(posStoreMapper.selectPosStoreById(7L)).thenReturn(store);
+        when(infoUserService.selectInfoUserByUserId(10L)).thenReturn(contactUser(10L, "1", "1"));
+        when(relationService.selectEligibleSubaccountsByStoreId(7L)).thenReturn(List.of());
+
+        StoreAccountsView view = service.listStoreAccounts(7L);
+
+        assertNull(view.getOwner());
+        assertEquals(List.of(), view.getSubaccounts());
+    }
+
+    private MerchantSubaccountApplicationService newService(PosStoreMapper posStoreMapper) {
+        return newService(mock(IInfoUserService.class), mock(IMerchantSubaccountStoreService.class), posStoreMapper);
+    }
+
+    private MerchantSubaccountApplicationService newService(IInfoUserService infoUserService,
+                                                            IMerchantSubaccountStoreService relationService,
+                                                            PosStoreMapper posStoreMapper) {
+        return new MerchantSubaccountApplicationService(infoUserService, relationService,
+                mock(MerchantStoreAccessService.class), posStoreMapper,
+                mock(MerchantTokenSessionService.class), mock(BusinessPhoneService.class));
+    }
+
+    private InfoUser contactUser(Long userId, String userType, String status) {
+        InfoUser user = new InfoUser();
+        user.setUserId(userId);
+        user.setUserType(userType);
+        user.setStatus(status);
+        user.setDelFlag("0");
+        user.setNickName("nick-" + userId);
+        user.setTelPhone("tel-" + userId);
+        user.setAvatar("avatar-" + userId);
+        user.setImApiKey("imKey-" + userId);
+        user.setImUserId(900000L + userId);
+        user.setCid("cid-" + userId);
+        user.setCidType("ios");
+        user.setDeviceToken("device-" + userId);
+        user.setVoIPToken("voip-" + userId);
+        return user;
+    }
 }

+ 12 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/user/MerchantSubaccountControllerTest.java

@@ -5,6 +5,7 @@ import com.ruoyi.app.user.dto.MerchantSubaccountPasswordRequest;
 import com.ruoyi.app.user.dto.MerchantSubaccountStatusRequest;
 import com.ruoyi.app.user.dto.MerchantSubaccountUpdateRequest;
 import com.ruoyi.app.user.dto.MerchantSubaccountView;
+import com.ruoyi.app.user.dto.StoreAccountsView;
 import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.system.utils.AuthContext;
@@ -57,6 +58,17 @@ class MerchantSubaccountControllerTest {
         verify(applicationService).listForOwner(10L);
     }
 
+    @Test
+    void listByStoreDelegatesQueryByStoreId() {
+        StoreAccountsView view = new StoreAccountsView();
+        when(applicationService.listStoreAccounts(7L)).thenReturn(view);
+
+        AjaxResult result = controller.listByStore("token", 7L);
+
+        assertEquals(view, result.get(AjaxResult.DATA_TAG));
+        verify(applicationService).listStoreAccounts(7L);
+    }
+
     @Test
     void mutationsAlwaysUseAuthenticatedOwner() {
         MerchantSubaccountCreateRequest create = new MerchantSubaccountCreateRequest();