Explorar o código

修复分管账号摊位列表的门店授权范围

复用实时门店授权集合,无授权时返回空列表,保留原有账号查询行为。补充回归测试和规格记录,已通过二十项定向测试及模块打包。
qmj hai 4 días
pai
achega
e16b0bfd49

+ 17 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/stall/StallController.java

@@ -15,6 +15,8 @@ import com.ruoyi.system.mapper.InfoUserMapper;
 import com.ruoyi.system.service.IInfoUserService;
 import com.ruoyi.system.service.IPosStoreService;
 import com.ruoyi.system.service.IUserWalletService;
+import com.ruoyi.system.service.MerchantStoreAccessService;
+import com.ruoyi.system.domain.constants.MerchantAccountConstants;
 import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.JwtUtil;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -22,6 +24,7 @@ import org.springframework.transaction.annotation.Transactional;
 import org.springframework.web.bind.annotation.*;
 
 import java.util.List;
+import java.util.Set;
 
 /**
  * 摊位管理Controller
@@ -45,10 +48,14 @@ public class StallController extends BaseController {
     @Autowired
     private BusinessPhoneService businessPhoneService;
 
+    @Autowired
+    private MerchantStoreAccessService merchantStoreAccessService;
+
     /**
      * 获取摊位列表
      * 夜市用户(type=3):返回自己创建的所有摊位
      * 摊位主(type=4):返回自己关联的摊位
+     * 商家分管账号(type=5):只返回当前授权的门店
      */
     @Anonymous
     @Auth
@@ -67,6 +74,16 @@ public class StallController extends BaseController {
             return success(store != null ? java.util.Collections.singletonList(store) : java.util.Collections.emptyList());
         }
 
+        if (MerchantAccountConstants.SUBACCOUNT_USER_TYPE.equals(loginUser.getUserType())) {
+            Set<Long> storeIds = merchantStoreAccessService.getAccessibleStoreIds(loginUser.getUserId());
+            if (storeIds.isEmpty()) {
+                return success(java.util.Collections.emptyList());
+            }
+            QueryWrapper<PosStore> query = new QueryWrapper<>();
+            query.in("id", storeIds);
+            return success(posStoreService.list(query));
+        }
+
         // 夜市用户:获取自己创建的所有摊位
         PosStore query = new PosStore();
         query.setUserId(Long.valueOf(userId));

+ 107 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/stall/StallListAccessTest.java

@@ -0,0 +1,107 @@
+package com.ruoyi.app.stall;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+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.PosStore;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IPosStoreService;
+import com.ruoyi.system.service.MerchantStoreAccessService;
+import com.ruoyi.system.utils.JwtUtil;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class StallListAccessTest {
+    private final IInfoUserService users = mock(IInfoUserService.class);
+    private final IPosStoreService stores = mock(IPosStoreService.class);
+    private final MerchantStoreAccessService access = mock(MerchantStoreAccessService.class);
+    private final StallController controller = new StallController();
+    private final InfoUser user = new InfoUser();
+    private String token;
+    private MockedStatic<MessageUtils> messages;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        messages = mockStatic(MessageUtils.class);
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        ReflectionTestUtils.setField(controller, "posStoreService", stores);
+        ReflectionTestUtils.setField(controller, "merchantStoreAccessService", access);
+        user.setUserId(50L);
+        user.setUserType("5");
+        user.setStoreId(999L);
+        when(users.selectInfoUserByUserId(50L)).thenReturn(user);
+        token = JwtUtil.setToken("50", "subaccount");
+    }
+
+    @AfterEach
+    void tearDown() {
+        messages.close();
+    }
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void subaccountQueriesOnlyCurrentAuthorizedStores() {
+        when(access.getAccessibleStoreIds(50L)).thenReturn(Set.of(8L, 9L));
+        PosStore store = new PosStore();
+        store.setId(8);
+        when(stores.list(any(QueryWrapper.class))).thenReturn(List.of(store));
+
+        assertEquals(List.of(store), controller.getStallList(token).get(AjaxResult.DATA_TAG));
+
+        ArgumentCaptor<QueryWrapper> query = ArgumentCaptor.forClass(QueryWrapper.class);
+        verify(stores).list(query.capture());
+        assertTrue(query.getValue().getSqlSegment().contains("id IN"));
+        assertEquals(Set.of(8L, 9L), Set.copyOf(query.getValue().getParamNameValuePairs().values()));
+        verify(stores, never()).selectPosStoreList(any());
+        verify(stores, never()).selectPosStoreById(any());
+    }
+
+    @Test
+    void emptyAuthorizationReturnsEmptyWithoutQueryingStores() {
+        when(access.getAccessibleStoreIds(50L)).thenReturn(Set.of());
+        assertEquals(List.of(), controller.getStallList(token).get(AjaxResult.DATA_TAG));
+        verifyNoInteractions(stores);
+    }
+
+    @Test
+    void unavailableAccountCannotFallBackToOwnerQuery() {
+        when(access.getAccessibleStoreIds(50L)).thenThrow(new ServiceException("unavailable"));
+        assertThrows(ServiceException.class, () -> controller.getStallList(token));
+        verifyNoInteractions(stores);
+    }
+
+    @Test
+    void ownerAndNightMarketKeepExistingQuery() {
+        for (String type : List.of("1", "3")) {
+            user.setUserType(type);
+            controller.getStallList(token);
+        }
+        ArgumentCaptor<PosStore> query = ArgumentCaptor.forClass(PosStore.class);
+        verify(stores, times(2)).selectPosStoreList(query.capture());
+        query.getAllValues().forEach(value -> assertEquals(50L, value.getUserId()));
+        verifyNoInteractions(access);
+    }
+
+    @Test
+    void stallOwnerKeepsBoundStoreQuery() {
+        user.setUserType("4");
+        PosStore store = new PosStore();
+        store.setId(999);
+        when(stores.selectPosStoreById(999L)).thenReturn(store);
+        assertEquals(List.of(store), controller.getStallList(token).get(AjaxResult.DATA_TAG));
+        verifyNoInteractions(access);
+    }
+}

+ 4 - 0
specs/022-merchant-store-subaccounts/plan.md

@@ -200,3 +200,7 @@ ruoyi-admin/src/test/java/com/ruoyi/app/order/DeliveryOrderNotificationServiceTe
 | 集中权限服务 | 多个旧 Controller 的归属规则不同,必须统一防止 IDOR | 仅前端隐藏或各 Controller 手写条件容易漏改 |
 | 通知路由服务 | 同店多接收者、消息逐账号归属、主账号兜底需要统一行为 | 在每个订单入口复制循环会产生规则漂移 |
 | `@Auth` 可选会话认证 | 在不改变骑手、普通用户认证行为的前提下,把商家 JWT 与 Redis 会话校验集中到 Controller 之前 | 全局强制 Redis 校验会扩大兼容性影响;Controller 手工调用会产生重复认证和遗漏风险 |
+
+## 2026-11-04 `/stall/list` 授权修复
+
+在 `StallController.getStallList` 增加类型 5 分支,复用 `MerchantStoreAccessService.getAccessibleStoreIds` 解析实时权限;空集合直接返回空列表,否则通过 `id IN` 查询。新增定向测试覆盖授权集合、空授权、账号不可用及既有角色兼容,并联跑集中权限服务和现有摊位测试。无数据库结构变更。

+ 5 - 0
specs/022-merchant-store-subaccounts/spec.md

@@ -207,3 +207,8 @@
 - 无授权门店时列表返回空集合,不能省略过滤条件;显式访问未授权门店时拒绝。非商家角色不得读取管理列表。授权撤销、账号停用、主账号停用以及门店转移/删除均通过现有集中权限服务实时生效。
 - 餐桌码的夜市及摊位账号沿用现有权限规则;顾客扫码 `/table-qrcode/scan` 行为不变。
 - 支付方式及银行卡遵循后续 `031-pay-method-config` 的主账号共享设置规格,不据此恢复旧的子账号禁用规则。
+
+## 2026-11-04 `/stall/list` 分管账号授权补漏
+
+- 商家分管账号(`userType=5`)调用 `/stall/list` 时,只返回实时授权且仍有效的门店;不得按分管账号 ID 查询门店归属,也不得回退为主账号全部门店。
+- 无授权门店返回空列表;账号或主账号不可用时沿用集中权限服务拒绝访问。普通商家、夜市和摊位主的原有查询规则保持不变。

+ 8 - 0
specs/022-merchant-store-subaccounts/tasks.md

@@ -387,3 +387,11 @@ T005..T007 --------------------------> T012
 - [x] 定向回归、JDK 21 模块构建及差异/换行检查,记录实际结果;不部署、不执行数据库变更。
 
 验证记录:首轮 `MerchantLegacyEntryAccessTest` 15 项全部复现失败;补充摊位场景后 19 项中 2 项复现摊位遗漏。最终 JDK 21 执行 `mvn -q -pl ruoyi-admin -am -Dtest=MerchantLegacyEntryAccessTest,TableQrcodeControllerTest,LinePayOfflineMerchantAccessTest,LinePayOfflineServiceTest,MerchantStoreAccessServiceTest,PosOrderShOprateControllerTest,LinePayOrderGuardTest,LinePayOfflinePersistenceContractTest -Dsurefire.failIfNoSpecifiedTests=false package`,退出码 0,86 项测试全部通过。日志为 `ruoyi-admin/target/merchant-legacy-access-green.log`;`git diff --check` 通过,变更文件保持 CRLF。未执行线上部署、真实支付/数据库变更或全量回归,提交状态以 Git 记录为准,尚未部署。
+
+## T014 `/stall/list` 分管门店授权补漏(2026-11-04)
+
+- [x] 定位分管账号错误沿用按账号归属查询的分支,补充定向回归测试。
+- [x] 类型 5 列表接入实时授权集合,空授权返回空列表,保留原有角色行为。
+- [x] 运行 JDK 21 定向回归与模块构建,检查差异与换行。
+
+验证记录:为避免同工作区另一项 Maven 构建干扰,在独立源码快照中使用本机 JDK 21.0.12。修复前新测试 5 项中 3 项按预期失败;修复后执行 `mvn -q -pl ruoyi-admin -am -Dtest=StallListAccessTest,StallControllerTest,MerchantStoreAccessServiceTest -Dsurefire.failIfNoSpecifiedTests=false package`,退出码 0,20 项测试全部通过。日志保存至 `ruoyi-admin/target/stall-list-red.log`、`ruoyi-admin/target/stall-list-green.log`。差异与换行检查通过;未执行全量回归、真实数据库/API 联调或部署;提交与推送状态以 Git 记录为准。