Просмотр исходного кода

提交630第二阶段菜单配置

chenxiaofei 6 дней назад
Родитель
Сommit
7e44d3358c

+ 222 - 116
sckw-auth/src/main/java/com/sckw/auth/service/impl/AuthServiceImpl.java

@@ -919,178 +919,284 @@ public class AuthServiceImpl implements IAuthService {
                 || Objects.equals(clientType, ClientTypeEnum.mobile.getValue());
     }
     
+
     /**
      * 根据角色ID字典配置控制APP模块权限。
      * <p>
      * 该方法主要用于在登录或刷新Token时,根据当前用户的角色配置,动态设置APP端需要展示的模块(如订单统计、销售统计、钱包、地址管理等)。
      * 仅在APP端登录时生效,PC/H5端不展示这些特定模块配置。
+     * </p>
      *
      * @param loginRes  登录返回信息对象,用于设置模块显示状态及钱包子项
      * @param loginBase 登录请求基础信息,用于判断客户端类型
      */
     private void applyAppModulePermissionsByConfig(LoginResVo1 loginRes, LoginBase loginBase) {
-        // 1. 初始化默认值:隐藏所有特定模块,清空钱包子项
-        // 确保在未匹配到任何角色或配置异常时,前端不会错误展示敏感或无关模块
+        // 1. 参数非空校验:若登录结果或登录参数为空,直接返回,避免NPE
+        if (Objects.isNull(loginRes) || Objects.isNull(loginBase)) {
+            log.debug("登录返回对象或登录参数为空,跳过APP模块权限配置");
+            return;
+        }
+
+        // 2. 初始化默认状态:默认隐藏所有APP工作台模块,遵循“最小权限原则”,避免无菜单权限时前端误展示。
         loginRes.setShowOrderStatisticsModule(Boolean.FALSE);
         loginRes.setShowSalesStatisticsModule(Boolean.FALSE);
         loginRes.setShowWalletModule(Boolean.FALSE);
         loginRes.setShowAddressModule(Boolean.FALSE);
         loginRes.setWalletModuleItems(Collections.emptyList());
 
-        // 2. 校验客户端类型:仅对APP端(iOS/Android/Mobile)进行模块权限控制
+        // 3. 客户端类型校验:仅对APP端(iOS/Android/Mobile)进行模块权限配置,PC/H5端直接返回
         if (!isAppLogin(loginBase)) {
             log.debug("非APP端登录,跳过APP模块权限配置。clientType: {}", loginBase.getClientType());
             return;
         }
 
-        // 3. 加载当前角色的APP权限配置
-        // 从字典表 app_role_permission 中读取角色ID对应的权限标识
+        // 4. 获取角色ID并加载对应的APP菜单配置
         Long roleId = loginRes.getRoleId();
-        AppRoleConfig appRoleConfig = loadAppRoleConfig(roleId);
-        log.info("开始配置APP模块权限 - userId: {}, roleId: {}, config: {}", loginRes.getId(), roleId, appRoleConfig.roleTypes);
+        log.debug("开始加载APP模块权限配置,userId: {}, roleId: {}", loginRes.getId(), roleId);
+        
+        // 通过远程服务查询该角色在APP端的菜单权限列表
+        List<AppTabBarMenuResDto> appMenus = loadAppRoleMenus(roleId);
 
-        // 4. 匹配角色类型并设置相应的模块显示状态
-        boolean isSeller = appRoleConfig.match(AppRoleType.SELLER);   // 销售/供应商
-        boolean isFinance = appRoleConfig.match(AppRoleType.FINANCE); // 财务
-        boolean isPurchase = appRoleConfig.match(AppRoleType.PURCHASE); // 采购/买家
+        // 5. 逐项判断模块权限:
+        // 使用多关键字匹配(中文名、英文名、下划线格式、驼峰格式等),提高匹配的兼容性和健壮性。
+        
+        // 5.1 订单统计模块:检查菜单中是否包含“展示订单统计”或相关标识(orderStatistics等),决定前端是否显示该模块
+        boolean hasOrderStatistics = hasAppModuleMenu(appMenus, "\u5c55\u793a\u8ba2\u5355\u7edf\u8ba1", "orderStatistics", "order-statistics", "order_statistics");
+        log.debug("订单统计模块权限判断结果: {}", hasOrderStatistics);
+        
+        // 5.2 销售统计模块:检查菜单中是否包含“展示销售统计”或相关标识(salesStatistics等),决定前端是否显示该模块
+        boolean hasSalesStatistics = hasAppModuleMenu(appMenus, "\u5c55\u793a\u9500\u552e\u7edf\u8ba1", "salesStatistics", "sales-statistics", "sales_statistics");
+        log.debug("销售统计模块权限判断结果: {}", hasSalesStatistics);
+        
+        // 5.3 钱包模块:检查菜单中是否包含“展示钱包”或相关标识(wallet),决定前端是否显·示钱包入口
+        boolean hasWallet = hasAppModuleMenu(appMenus, "\u5c55\u793a\u94b1\u5305", "wallet");
+        log.debug("钱包模块权限判断结果: {}", hasWallet);
+        
+        // 5.4 地址管理模块:检查菜单中是否包含“展示地址”或相关标识(address),决定前端是否显示地址管理入口
+        boolean hasAddress = hasAppModuleMenu(appMenus, "\u5c55\u793a\u5730\u5740", "address");
+        log.debug("地址管理模块权限判断结果: {}", hasAddress);
 
-        // 4.1 销售/供应商角色权限
-        // 展示:订单统计、销售统计
-        if (isSeller) {
-            log.info("用户角色匹配[SELLER],开启订单统计与销售统计模块");
-            loginRes.setShowOrderStatisticsModule(Boolean.TRUE);
-            loginRes.setShowSalesStatisticsModule(Boolean.TRUE);
-            return;
-        }
+        // 5.5 钱包子项 - 待履约保证金:检查菜单中是否包含“展示待履约保证金”或相关标识,用于控制钱包内子项显示
+        boolean hasPendingPerformance = hasAppModuleMenu(appMenus, "\u5c55\u793a\u5f85\u5c65\u7ea6\u4fdd\u8bc1\u91d1", "PENDING_PERFORMANCE_BALANCE", "pendingPerformanceBalance", "pending-performance-balance");
+        log.debug("待履约保证金子项权限判断结果: {}", hasPendingPerformance);
 
-        // 4.2 财务角色权限
-        // 展示:订单统计、钱包模块
-        // 钱包子项:待履约保证金(PENDING_PERFORMANCE_BALANCE)、待付运费(PENDING_FREIGHT)
-        if (isFinance) {
-            log.info("用户角色匹配[FINANCE],开启订单统计与钱包模块(待履约保证金、待付运费)");
-            loginRes.setShowOrderStatisticsModule(Boolean.TRUE);
-            loginRes.setShowWalletModule(Boolean.TRUE);
-            loginRes.setWalletModuleItems(Arrays.asList("PENDING_PERFORMANCE_BALANCE", "PENDING_FREIGHT"));
-            return;
+        // 5.6 钱包子项 - 预付余额:检查菜单中是否包含“展示预付余额”或相关标识,用于控制钱包内子项显示
+        boolean hasPrepayBalance = hasAppModuleMenu(appMenus, "\u5c55\u793a\u9884\u4ed8\u4f59\u989d", "PREPAY_BALANCE", "prepayBalance", "prepay-balance");
+        log.debug("预付余额子项权限判断结果: {}", hasPrepayBalance);
+        
+        // 5.7 钱包子项 - 待付运费:检查菜单中是否包含“展示待付运费”或相关标识,用于控制钱包内子项显示
+        boolean hasPendingFreight = hasAppModuleMenu(appMenus, "\u5c55\u793a\u5f85\u4ed8\u8fd0\u8d39", "PENDING_FREIGHT", "pendingFreight", "pending-freight");
+        log.debug("待付运费子项权限判断结果: {}", hasPendingFreight);
+
+        // 6. 应用权限配置到返回对象
+        loginRes.setShowOrderStatisticsModule(hasOrderStatistics);
+        loginRes.setShowSalesStatisticsModule(hasSalesStatistics);
+        loginRes.setShowWalletModule(hasWallet);
+        loginRes.setShowAddressModule(hasAddress);
+        
+        // 若拥有钱包模块权限,则进一步构建钱包内部的子项列表
+        if (hasWallet) {
+            List<String> walletItems = buildWalletModuleItems(hasPendingPerformance, hasPrepayBalance, hasPendingFreight);
+            loginRes.setWalletModuleItems(walletItems);
+            log.debug("钱包模块开启,子项配置: {}", walletItems);
         }
 
-        // 4.3 采购/买家角色权限
-        // 展示:订单统计、钱包模块、地址管理模块
-        // 钱包子项:预付余额(PREPAY_BALANCE)、待付运费(PENDING_FREIGHT)
-        if (isPurchase) {
-            log.info("用户角色匹配[PURCHASE],开启订单统计、钱包模块(预付余额、待付运费)及地址管理模块");
-            loginRes.setShowOrderStatisticsModule(Boolean.TRUE);
-            loginRes.setShowWalletModule(Boolean.TRUE);
-            loginRes.setShowAddressModule(Boolean.TRUE);
-            loginRes.setWalletModuleItems(Arrays.asList("PREPAY_BALANCE", "PENDING_FREIGHT"));
-        } else {
-            // 未匹配到上述特定角色,保持默认隐藏状态
-            log.debug("用户角色未匹配SELLER/FINANCE/PURCHASE,保持模块默认隐藏状态");
+        // 7. 记录最终配置结果日志,便于后续排查权限问题
+        log.info("APP模块权限配置完成 - userId: {}, roleId: {}, orderStats: {}, salesStats: {}, wallet: {}, address: {}",
+                loginRes.getId(), roleId, hasOrderStatistics, hasSalesStatistics, hasWallet, hasAddress);
+    }
+
+
+    /**
+     * 根据菜单授权结果构建钱包模块子项。
+     *
+     * @param hasPendingPerformance 是否包含待履约保证金菜单
+     * @param hasPrepayBalance      是否包含预付余额菜单
+     * @param hasPendingFreight     是否包含待付运费菜单
+     * @return 钱包模块子项编码列表
+     */
+    List<String> buildWalletModuleItems(boolean hasPendingPerformance, boolean hasPrepayBalance, boolean hasPendingFreight) {
+        List<String> walletItems = new ArrayList<>();
+        if (hasPendingPerformance) {
+            walletItems.add("PENDING_PERFORMANCE_BALANCE");
         }
+        if (hasPrepayBalance) {
+            walletItems.add("PREPAY_BALANCE");
+        }
+        if (hasPendingFreight || hasPendingPerformance || hasPrepayBalance) {
+            walletItems.add("PENDING_FREIGHT");
+        }
+        return walletItems;
     }
 
+    /**
+     * 加载角色APP菜单。
+     *
+     * @param roleId 角色ID
+     * @return APP菜单列表
+     */
+    /**
+     * 加载指定角色的APP底部导航菜单配置。
+     * <p>
+     * 该方法通过远程服务查询角色对应的APP TabBar菜单权限,用于动态构建APP端底部导航栏。
+     * 若角色ID为空、查询结果为空或发生异常,均返回空列表,确保前端展示默认状态或不展示导航栏,避免影响登录流程。
+     * </p>
+     *
+     * @param roleId 角色ID,用于查询对应的APP底部导航配置
+     * @return APP底部导航菜单项列表,若无配置或查询失败则返回空列表
+     */
+    List<AppTabBarMenuResDto> loadAppRoleMenus(Long roleId) {
+        // 1. 参数校验:若角色ID为空,直接返回空列表,避免无效远程调用
+        if (Objects.isNull(roleId)) {
+            log.debug("角色ID为空,跳过APP角色菜单查询");
+            return Collections.emptyList();
+        }
+
+        try {
+            // 2. 记录开始查询日志,便于追踪性能和问题
+            log.debug("开始查询APP角色菜单,roleId: {}", roleId);
+
+            // 3. 远程调用系统服务,获取角色对应的APP底部导航菜单配置
+            List<AppTabBarMenuResDto> menus = remoteUserService.queryAppTabBarMenuByRoleId(roleId);
+
+            // 4. 结果校验:若返回结果为空或无数据,记录调试日志并返回空列表
+            if (org.apache.commons.collections4.CollectionUtils.isEmpty(menus)) {
+                log.debug("角色未配置APP底部导航菜单,roleId: {}", roleId);
+                return Collections.emptyList();
+            }
+
+            // 5. 记录成功查询日志,包含菜单数量信息
+            log.info("APP角色菜单查询成功,roleId: {}, 菜单数量: {}", roleId, menus.size());
+            return menus;
+
+        } catch (Exception e) {
+            // 6. 异常处理:记录错误日志,防止因远程服务异常导致登录流程中断
+            // 返回空列表,前端可根据此情况展示默认导航或隐藏导航栏
+            log.error("查询APP角色菜单失败,roleId: {}", roleId, e);
+            return Collections.emptyList();
+        }
+    }
 
     /**
-     * 根据角色ID字典配置构建APP底部导航。
+     * 判断角色菜单中是否包含指定APP模块
      *
-     * @param loginRes     登录返回信息,用于设置角色名称等上下文信息
-     * @param loginBase    登录请求信息,用于判断客户端类型
-     * @param roleName     当前角色名称,仅作为未配置字典时的兼容兜底(本方法主要依赖 roleId)
-     * @param flag         预留参数,兼容原方法签名,暂未使用
-     * @param entTypeNames 企业类型标识(1:供应商, 2:采购商, 3:4PL物流),用于区分特定管理员角色
-     * @return APP底部导航配置列表,若非APP端登录或无匹配角色则返回空列表
+     * @param menus    APP菜单列表
+     * @param keywords 模块关键字
+     * @return true-包含,false-不包含
      */
+    boolean hasAppModuleMenu(List<AppTabBarMenuResDto> menus, String... keywords) {
+        if (org.apache.commons.collections4.CollectionUtils.isEmpty(menus) || Objects.isNull(keywords)) {
+            return false;
+        }
+        return menus.stream()
+                .filter(Objects::nonNull)
+                .anyMatch(menu -> Arrays.stream(keywords)
+                        .filter(org.apache.commons.lang3.StringUtils::isNotBlank)
+                        .anyMatch(keyword -> containsIgnoreCase(menu.getName(), keyword)
+                                || containsIgnoreCase(menu.getPerms(), keyword)
+                                || containsIgnoreCase(menu.getUrl(), keyword)));
+    }
+
+    private boolean containsIgnoreCase(String source, String keyword) {
+        return org.apache.commons.lang3.StringUtils.isNotBlank(source)
+                && org.apache.commons.lang3.StringUtils.containsIgnoreCase(source, keyword);
+    }
+
     private List<LoginResVo1.TabBarItem> buildAppTabBarByConfig(LoginResVo1 loginRes, LoginBase loginBase,
                                                                 String roleName, int flag, String entTypeNames) {
         // 优先使用登录结果中的角色名称,若为空则使用传入的参数
+        if (Objects.isNull(loginRes)) {
+            log.debug("登录返回对象为空,跳过APP底部导航菜单查询");
+            return List.of();
+        }
         roleName = org.apache.commons.lang3.StringUtils.defaultIfBlank(loginRes.getRoleName(), roleName);
         
         // 记录关键上下文信息,便于排查角色匹配问题
         log.info("构建APP TabBar - 角色ID:{},角色名称:{},企业类型:{}", loginRes.getRoleId(), roleName, entTypeNames);
 
         // 非APP端登录(如PC、H5等)不展示底部导航
+        // 校验客户端类型:仅对APP端(iOS/Android/Mobile)构建底部导航,PC/H5端返回空列表
         if (!isAppLogin(loginBase)) {
+            log.debug("非APP端登录(clientType: {}),跳过APP底部导航构建", loginBase.getClientType());
             return List.of();
         }
 
-        // 加载基于字典配置的角色权限映射
-        AppRoleConfig appRoleConfig = loadAppRoleConfig(loginRes.getRoleId());
-
-        // 1. 采购员角色:直接返回买家导航
-        if (appRoleConfig.match(AppRoleType.PURCHASE)) {
-            return buildBuyerTabBar();
-        }
+        Long roleId = loginRes.getRoleId();
+        log.info("开始构建APP底部导航 - userId: {}, roleId: {}", loginRes.getId(), roleId);
 
-        // 2. 解析具体角色标识
-        boolean isDoorKeeper = appRoleConfig.match(AppRoleType.DOOR_KEEPER);      // 门卫
-        boolean isForkliftDriver = appRoleConfig.match(AppRoleType.FORKLIFT_DRIVER); // 叉车司机
-        boolean isBuyer = appRoleConfig.match(AppRoleType.BUYER);                 // 买家
-        boolean isSeller = appRoleConfig.match(AppRoleType.SELLER);               // 销售/供应商
-        boolean isDriver = appRoleConfig.match(AppRoleType.DRIVER);               // 司机
-        boolean isLogistics = appRoleConfig.match(AppRoleType.LOGISTICS);         // 物流人员
-        boolean isFinance = appRoleConfig.match(AppRoleType.FINANCE);             // 财务
-
-        // 3. 解析特定企业管理员角色(需同时满足角色标识和企业类型)
-        // 供应商管理员:角色匹配且企业类型为"1"
-        boolean isSupplierAdmin = appRoleConfig.match(AppRoleType.SUPPLIER_ADMIN)
-                && org.apache.commons.lang3.StringUtils.equals(entTypeNames, "1");
-        // 物流商管理员:角色匹配且企业类型为"3"
-        boolean isLogisticsAdmin = appRoleConfig.match(AppRoleType.LOGISTICS_ADMIN)
-                && org.apache.commons.lang3.StringUtils.equals(entTypeNames, "3");
-        // 采购商管理员:角色匹配且企业类型为"2"
-        boolean isPurchaseAdmin = appRoleConfig.match(AppRoleType.PURCHASE_ADMIN)
-                && org.apache.commons.lang3.StringUtils.equals(entTypeNames, "2");
-
-        // 4. 按优先级匹配并返回对应的TabBar配置
-        if (isDoorKeeper) {
-            return buildDoorKeeperTabBar();
-        }
-        if (isForkliftDriver) {
-            return buildForkliftDriverTabBar();
-        }
-        if (isBuyer) {
-            return buildBuyerTabBar();
-        }
-        // 销售或财务共用卖家导航
-        if (isSeller || isFinance) {
-            return buildSellerTabBar();
-        }
-        if (isDriver) {
-            return buildDefaultDriverTabBar();
-        }
-        if (isLogistics) {
-            return buildLogisticsTabBar();
-        }
+        // 从角色菜单权限加载APP底部导航,替代原有的硬编码字典配置,实现动态配置
+        List<LoginResVo1.TabBarItem> tabBarItems = buildAppTabBarByRoleMenus(roleId);
         
-        // 5. 处理特定管理员角色,并修正显示的角色名称
-        if (isSupplierAdmin) {
-            loginRes.setRoleName("供应商管理员");
-            return buildSupplierAdminTabBar();
-        }
-        if (isLogisticsAdmin) {
-            loginRes.setRoleName("物流商管理员");
-            return buildLogisticsAdminTabBar();
-        }
-        if (isPurchaseAdmin) {
-            log.info("用户角色配置为采购管理员且企业属性为采购商,返回采购管理员TabBar");
-            loginRes.setRoleName("采购商管理员");
-            return buildPurchaseAdminTabBar();
-        }
-
-        // 未匹配到任何已知角色,返回空列表
-        return List.of();
+        log.info("APP底部导航构建完成 - userId: {}, roleId: {}, 菜单数量: {}", 
+                loginRes.getId(), roleId, tabBarItems != null ? tabBarItems.size() : 0);
+        
+        return tabBarItems;
     }
 
 
     /**
-     * 加载APP角色权限配置
+     * 根据角色ID查询并构建APP底部导航栏菜单项。
      * <p>
-     * 该方法从系统字典服务中查询类型为 {@link DictTypeEnum#APP_ROLE_PERMISSION} 的字典项,
-     * 并将其转换为 {@link AppRoleConfig} 对象,用于后续判断当前用户角色对应的APP端权限(如TabBar显示、模块可见性等)。
+     * 该方法通过远程服务获取指定角色配置的APP底部导航菜单数据,
+     * 并将其转换为前端所需的TabBarItem对象列表。
+     * 若角色ID为空、未配置菜单或查询异常,则返回空列表,确保前端展示默认状态或不展示底部导航。
      * </p>
      *
-     * @param roleId 当前登录用户的角色ID
-     * @return {@link AppRoleConfig} 包含该角色匹配的APP角色类型集合;若roleId为空或查询异常,则返回空配置
+     * @param roleId 角色ID,用于查询对应的APP底部导航配置
+     * @return APP底部导航菜单项列表,若无配置或查询失败则返回空列表
+     */
+    List<LoginResVo1.TabBarItem> buildAppTabBarByRoleMenus(Long roleId) {
+        // 1. 参数校验:若角色ID为空,直接返回空列表,避免无效远程调用
+        if (Objects.isNull(roleId)) {
+            log.debug("角色ID为空,跳过APP底部导航菜单查询");
+            return List.of();
+        }
+
+        try {
+            // 2. 记录开始查询日志,便于追踪性能和问题
+            log.debug("开始查询APP底部导航菜单,roleId: {}", roleId);
+
+            // 3. 远程调用系统服务,获取角色对应的APP底部导航菜单配置
+            List<AppTabBarMenuResDto> menus = remoteUserService.queryAppTabBarMenuByRoleId(roleId);
+
+            // 4. 结果校验:若返回结果为空或无数据,记录调试日志并返回空列表
+            if (org.apache.commons.collections4.CollectionUtils.isEmpty(menus)) {
+                log.debug("角色未配置APP底部导航菜单,roleId: {}", roleId);
+                return List.of();
+            }
+
+            // 5. 数据转换:将DTO列表转换为前端所需的TabBarItem对象列表
+            // 过滤掉null对象,并对每个菜单项进行字段默认值处理(防止前端展示空白)
+            List<LoginResVo1.TabBarItem> tabBarItems = menus.stream()
+                    .filter(Objects::nonNull)
+                    .map(menu -> buildTabBarItem(
+                            // 菜单名称,若为空则默认为空字符串
+                            org.apache.commons.lang3.StringUtils.defaultString(menu.getName()),
+                            // 选中图标路径,若为空则默认为空字符串
+                            org.apache.commons.lang3.StringUtils.defaultString(menu.getIcon()),
+                            // 未选中图标路径,若为空则默认为空字符串
+                            org.apache.commons.lang3.StringUtils.defaultString(menu.getNotSelectedIconPath()),
+                            // 页面路由地址,若为空则默认为空字符串
+                            org.apache.commons.lang3.StringUtils.defaultString(menu.getUrl())))
+                    .toList();
+
+            // 6. 记录成功构建日志,包含菜单数量信息
+            log.info("APP底部导航菜单构建成功,roleId: {}, 菜单数量: {}", roleId, tabBarItems.size());
+            return tabBarItems;
+
+        } catch (Exception e) {
+            // 7. 异常处理:记录错误日志,防止因远程服务异常导致登录流程中断
+            // 返回空列表,前端可根据此情况展示默认导航或隐藏导航栏
+            log.error("查询APP底部导航菜单失败,roleId: {}", roleId, e);
+            return List.of();
+        }
+    }
+
+    /**
+     * 加载APP模块权限角色配置。
+     *
+     * @param roleId 角色ID
+     * @return APP角色配置
      */
     private AppRoleConfig loadAppRoleConfig(Long roleId) {
         // 1. 参数校验:若角色ID为空,直接返回空配置,避免无效查询

+ 144 - 0
sckw-auth/src/test/java/com/sckw/auth/service/impl/AuthServiceImplTest.java

@@ -1,10 +1,20 @@
 package com.sckw.auth.service.impl;
 
+import com.sckw.auth.model.vo.req.LoginBase;
+import com.sckw.auth.model.vo.res.LoginResVo1;
 import com.sckw.system.api.model.dto.res.SysDictResDto;
+import com.sckw.system.api.RemoteUserService;
+import com.sckw.system.api.model.dto.res.AppTabBarMenuResDto;
 import org.junit.Assert;
 import org.junit.Test;
 
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 
 /**
@@ -40,10 +50,144 @@ public class AuthServiceImplTest {
         Assert.assertFalse(appRoleConfig.match(AuthServiceImpl.AppRoleType.PURCHASE));
     }
 
+    /**
+     * 验证APP底部导航通过角色菜单权限返回,并正确映射前端需要的字段。
+     */
+    @Test
+    public void appTabBarShouldBuildFromRoleMenus() throws Exception {
+        AuthServiceImpl authService = new AuthServiceImpl();
+        setRemoteUserService(authService, buildRemoteUserServiceProxy());
+
+        List<com.sckw.auth.model.vo.res.LoginResVo1.TabBarItem> tabBarItems =
+                authService.buildAppTabBarByRoleMenus(1001L);
+
+        Assert.assertEquals(1, tabBarItems.size());
+        com.sckw.auth.model.vo.res.LoginResVo1.TabBarItem item = tabBarItems.get(0);
+        Assert.assertEquals("首页", item.getText());
+        Assert.assertEquals("/static/icon/home-selected.png", item.getIconPath());
+        Assert.assertEquals("/static/icon/home.png", item.getNotSelectedIconPath());
+        Assert.assertEquals("/pages/home/index", item.getPagePath());
+    }
+
+    /**
+     * 验证APP模块开关受角色菜单结果控制:没有销售统计菜单时,不返回销售统计模块。
+     */
+    @Test
+    public void appModulePermissionsShouldFollowRoleMenus() throws Exception {
+        AuthServiceImpl authService = new AuthServiceImpl();
+        setRemoteUserService(authService, buildRemoteUserServiceProxy(buildOrderStatisticsMenu()));
+
+        LoginResVo1 loginRes = new LoginResVo1();
+        loginRes.setId(1L);
+        loginRes.setRoleId(9999L);
+        LoginBase loginBase = new LoginBase();
+        loginBase.setClientType("app");
+
+        Method method = AuthServiceImpl.class.getDeclaredMethod(
+                "applyAppModulePermissionsByConfig", LoginResVo1.class, LoginBase.class);
+        method.setAccessible(true);
+        method.invoke(authService, loginRes, loginBase);
+
+        Assert.assertTrue(loginRes.getShowOrderStatisticsModule());
+        Assert.assertFalse(loginRes.getShowSalesStatisticsModule());
+        Assert.assertFalse(loginRes.getShowWalletModule());
+        Assert.assertFalse(loginRes.getShowAddressModule());
+    }
+
+    /**
+     * 验证钱包、地址模块只由菜单结果控制,并按菜单子项返回钱包展示项。
+     */
+    @Test
+    public void appModulePermissionsShouldBuildWalletItemsFromMenus() throws Exception {
+        AuthServiceImpl authService = new AuthServiceImpl();
+        setRemoteUserService(authService, buildRemoteUserServiceProxy(
+                buildWalletMenu(), buildPrepayBalanceMenu(), buildPendingFreightMenu(), buildAddressMenu()));
+
+        LoginResVo1 loginRes = new LoginResVo1();
+        loginRes.setId(1L);
+        loginRes.setRoleId(9999L);
+        LoginBase loginBase = new LoginBase();
+        loginBase.setClientType("app");
+
+        Method method = AuthServiceImpl.class.getDeclaredMethod(
+                "applyAppModulePermissionsByConfig", LoginResVo1.class, LoginBase.class);
+        method.setAccessible(true);
+        method.invoke(authService, loginRes, loginBase);
+
+        Assert.assertFalse(loginRes.getShowOrderStatisticsModule());
+        Assert.assertFalse(loginRes.getShowSalesStatisticsModule());
+        Assert.assertTrue(loginRes.getShowWalletModule());
+        Assert.assertTrue(loginRes.getShowAddressModule());
+        Assert.assertEquals(Arrays.asList("PREPAY_BALANCE", "PENDING_FREIGHT"), loginRes.getWalletModuleItems());
+    }
+
     private SysDictResDto buildDict(String value, String label) {
         SysDictResDto dict = new SysDictResDto();
         dict.setValue(value);
         dict.setLabel(label);
         return dict;
     }
+
+    private RemoteUserService buildRemoteUserServiceProxy() {
+        AppTabBarMenuResDto menu = new AppTabBarMenuResDto();
+        menu.setName("首页");
+        menu.setIcon("/static/icon/home-selected.png");
+        menu.setNotSelectedIconPath("/static/icon/home.png");
+        menu.setUrl("/pages/home/index");
+        return buildRemoteUserServiceProxy(menu);
+    }
+
+    private AppTabBarMenuResDto buildOrderStatisticsMenu() {
+        AppTabBarMenuResDto menu = new AppTabBarMenuResDto();
+        menu.setName("\u8ba2\u5355\u7edf\u8ba1");
+        menu.setPerms("orderStatistics");
+        menu.setUrl("/pages/order/statistics");
+        return menu;
+    }
+
+    private AppTabBarMenuResDto buildWalletMenu() {
+        AppTabBarMenuResDto menu = new AppTabBarMenuResDto();
+        menu.setName("\u94b1\u5305");
+        menu.setPerms("wallet");
+        return menu;
+    }
+
+    private AppTabBarMenuResDto buildPrepayBalanceMenu() {
+        AppTabBarMenuResDto menu = new AppTabBarMenuResDto();
+        menu.setName("\u9884\u4ed8\u4f59\u989d");
+        menu.setPerms("PREPAY_BALANCE");
+        return menu;
+    }
+
+    private AppTabBarMenuResDto buildPendingFreightMenu() {
+        AppTabBarMenuResDto menu = new AppTabBarMenuResDto();
+        menu.setName("\u5f85\u4ed8\u8fd0\u8d39");
+        menu.setPerms("PENDING_FREIGHT");
+        return menu;
+    }
+
+    private AppTabBarMenuResDto buildAddressMenu() {
+        AppTabBarMenuResDto menu = new AppTabBarMenuResDto();
+        menu.setName("\u5730\u5740");
+        menu.setPerms("address");
+        return menu;
+    }
+
+    private RemoteUserService buildRemoteUserServiceProxy(AppTabBarMenuResDto... menus) {
+        return (RemoteUserService) Proxy.newProxyInstance(
+                RemoteUserService.class.getClassLoader(),
+                new Class[]{RemoteUserService.class},
+                (proxy, method, args) -> {
+                    if ("queryAppTabBarMenuByRoleId".equals(method.getName())) {
+                        return Arrays.asList(menus);
+                    }
+                    return null;
+                });
+    }
+
+    private void setRemoteUserService(AuthServiceImpl authService, RemoteUserService remoteUserService) throws Exception {
+        Field field = AuthServiceImpl.class.getDeclaredField("remoteUserService");
+        field.setAccessible(true);
+        field.set(authService, remoteUserService);
+    }
 }

+ 8 - 0
sckw-modules-api/sckw-system-api/src/main/java/com/sckw/system/api/RemoteUserService.java

@@ -107,6 +107,14 @@ public interface RemoteUserService {
      */
     List<UserAccessMenuInfoResDto> queryRoleMenu(Long roleId);
 
+    /**
+     * 根据角色ID查询APP底部导航菜单。
+     *
+     * @param roleId 角色ID
+     * @return APP底部导航菜单列表
+     */
+    List<AppTabBarMenuResDto> queryAppTabBarMenuByRoleId(Long roleId);
+
 
     /**
      * @param userLoginReqDto 用户登录信息

+ 51 - 0
sckw-modules-api/sckw-system-api/src/main/java/com/sckw/system/api/model/dto/res/AppTabBarMenuResDto.java

@@ -0,0 +1,51 @@
+package com.sckw.system.api.model.dto.res;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * APP底部导航菜单返回对象。
+ */
+@Data
+public class AppTabBarMenuResDto implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 753470578366262433L;
+
+    /**
+     * 菜单ID
+     */
+    private Long id;
+
+    /**
+     * 菜单名称
+     */
+    private String name;
+
+    /**
+     * 页面路径
+     */
+    private String url;
+
+    /**
+     * 权限标识
+     */
+    private String perms;
+
+    /**
+     * 选中菜单图标路径
+     */
+    private String icon;
+
+    /**
+     * 未选中菜单图标路径
+     */
+    private String notSelectedIconPath;
+
+    /**
+     * 排序
+     */
+    private Integer sort;
+}

+ 73 - 1
sckw-modules/sckw-system/src/main/java/com/sckw/system/dubbo/RemoteUserServiceImpl.java

@@ -28,6 +28,7 @@ import com.sckw.system.model.vo.res.KwsUserResVo;
 import com.sckw.system.model.vo.res.KwsUserSystemTypeVo;
 import com.sckw.system.service.*;
 import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
 import org.apache.dubbo.config.annotation.DubboService;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -39,7 +40,7 @@ import java.util.stream.Collectors;
  * @desc 远程接口
  * @date 2023/6/12
  */
-
+@Slf4j
 @DubboService(group = "design", version = "1.0.0")
 public class RemoteUserServiceImpl implements RemoteUserService {
 
@@ -81,6 +82,9 @@ public class RemoteUserServiceImpl implements RemoteUserService {
     @Resource
     private KwsRoleDao kwsRoleDao;
 
+    @Resource
+    private KwsMenuRightsDao kwsMenuRightsDao;
+
     @Resource
     private CommonService commonService;
 
@@ -124,6 +128,74 @@ public class RemoteUserServiceImpl implements RemoteUserService {
         return BeanUtils.copyToList(kwsMenuResVos, UserAccessMenuInfoResDto.class);
     }
 
+    /**
+     * 根据角色ID查询APP端底部导航栏(TabBar)菜单列表
+     * <p>
+     * 业务逻辑:
+     * 1. 校验角色ID有效性
+     * 2. 查询该角色关联的菜单权限ID
+     * 3. 根据菜单ID查询菜单详情
+     * 4. 过滤出符合APP端TabBar展示的菜单(类型=1, 客户端类型=3, 未删除)
+     * 5. 按排序字段排序并转换为DTO返回
+     *
+     * @param roleId 角色ID
+     * @return APP端TabBar菜单列表
+     */
+    @Override
+    public List<AppTabBarMenuResDto> queryAppTabBarMenuByRoleId(Long roleId) {
+        // 1. 参数校验
+        if (Objects.isNull(roleId)) {
+            log.warn("查询APP TabBar菜单失败,角色ID为空");
+            return Collections.emptyList();
+        }
+
+        // 2. 查询角色关联的菜单权限
+        List<KwsMenuRights> menuRightsList = kwsMenuRightsDao.selectByRoleIds(Collections.singletonList(roleId));
+        if (CollectionUtils.isEmpty(menuRightsList)) {
+            log.debug("角色ID: {} 未配置任何菜单权限", roleId);
+            return Collections.emptyList();
+        }
+
+        // 3. 提取有效的菜单ID列表
+        List<Long> menuIds = menuRightsList.stream()
+                .map(KwsMenuRights::getMenuId)
+                .filter(Objects::nonNull)
+                .distinct()
+                .toList();
+
+        if (CollectionUtils.isEmpty(menuIds)) {
+            log.warn("角色ID: {} 关联的菜单权限中无有效菜单ID", roleId);
+            return Collections.emptyList();
+        }
+
+        // 4. 批量查询菜单详情
+        List<KwsMenu> menus = kwsMenuService.selectByKeys(menuIds);
+        if (CollectionUtils.isEmpty(menus)) {
+            log.warn("根据菜单IDs: {} 未查询到对应的菜单信息", menuIds);
+            return Collections.emptyList();
+        }
+
+        // 5. 过滤、排序并转换DTO
+        // 过滤条件:
+        // - type == 1 (菜单类型)
+        // - clientType == 3 (APP端)
+        // - delFlag == 0 (未删除)
+        List<AppTabBarMenuResDto> result = menus.stream()
+                .filter(menu -> Objects.equals(menu.getType(), Global.NUMERICAL_ONE))
+                .filter(menu -> Objects.equals(menu.getClientType(), Global.NUMERICAL_THREE))
+                .filter(menu -> Objects.equals(menu.getDelFlag(), Global.NO))
+                .sorted(Comparator.comparing(KwsMenu::getSort, Comparator.nullsLast(Integer::compareTo)))
+                .map(menu -> {
+                    AppTabBarMenuResDto dto = new AppTabBarMenuResDto();
+                    BeanUtils.copyProperties(menu, dto);
+                    return dto;
+                })
+                .toList();
+
+        log.debug("角色ID: {} 查询到 {} 个APP TabBar菜单", roleId, result.size());
+        return result;
+    }
+
     @Override
     public void forgetPassword(ForgetPasswordReqDto reqDto) throws SystemException {
         com.sckw.system.model.vo.req.ForgetPasswordReqVo forgetPasswordReqVo = new ForgetPasswordReqVo();

+ 13 - 0
sckw-modules/sckw-system/src/main/java/com/sckw/system/service/KwsMenuService.java

@@ -863,6 +863,19 @@ public class KwsMenuService {
         return kwsMenuDao.selectAll();
     }
 
+    /**
+     * 根据菜单ID集合批量查询菜单。
+     *
+     * @param menuIds 菜单ID集合
+     * @return 菜单列表
+     */
+    public List<KwsMenu> selectByKeys(List<Long> menuIds) {
+        if (CollectionUtils.isEmpty(menuIds)) {
+            return Collections.emptyList();
+        }
+        return kwsMenuDao.selectByKeys(menuIds);
+    }
+
     @Transactional(rollbackFor = {})
     public void moveMenu(MoveMenuReqVo reqVo) {
         KwsMenu kwsMenu = kwsMenuDao.selectByKey(reqVo.getId());