“mitao”
2026-03-06 412fa709df33fcad4647f394fcebba8f68c2d471
feat(app):运维APP用户帐号权限获取
2个文件已添加
13个文件已修改
406 ■■■■■ 已修改文件
xp-robotaxi-account-boot/src/main/java/com/xiaopeng/account/controller/XpOperationsRoleController.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-api/src/main/java/com/xiaopeng/api/factory/account/XpOperationsRoleClientFallbackFactory.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-api/src/main/java/com/xiaopeng/api/feignClient/account/XpOperationsRoleClient.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-api/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/constant/I18nKeyConstant.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/controller/XpPointManagementController.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/mapper/XpPointMapper.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/service/XpPointService.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/service/serviceimpl/XpPointServiceImpl.java 125 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-map-boot/src/main/resources/i18n/messages.properties 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-map-boot/src/main/resources/i18n/messages_en_US.properties 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-map-boot/src/main/resources/i18n/messages_zh_CN.properties 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-operation-boot/src/main/java/com/xiaopeng/operation/controller/SysMenuController.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-operation-boot/src/main/java/com/xiaopeng/operation/service/ISysMenuService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-operation-boot/src/main/java/com/xiaopeng/operation/service/impl/SysMenuServiceImpl.java 144 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-account-boot/src/main/java/com/xiaopeng/account/controller/XpOperationsRoleController.java
@@ -6,9 +6,7 @@
import com.xiaopeng.api.model.account.XpOperationsRole;
import com.xiaopeng.common.core.domain.R;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@@ -43,5 +41,17 @@
        return R.ok(list);
    }
    /**
     * 内部调用
     * 查询运维人员关联的角色列表
     *
     * @param operationUserId 运维人员id
     */
    @GetMapping("/inner/xp-operations-role/listByOperationUserId")
    public R<List<XpOperationsRole>> listByOperationUserId(@RequestParam("operationUserId") Long operationUserId) {
        List<XpOperationsRole> list = xpOperationsRoleService.list(new QueryWrapper<XpOperationsRole>()
                .eq("operations_user_id", operationUserId));
        return R.ok(list);
    }
}
xp-robotaxi-api/src/main/java/com/xiaopeng/api/factory/account/XpOperationsRoleClientFallbackFactory.java
New file
@@ -0,0 +1,25 @@
package com.xiaopeng.api.factory.account;
import com.xiaopeng.api.feignClient.account.XpOperationsRoleClient;
import com.xiaopeng.api.model.account.XpOperationsRole;
import com.xiaopeng.common.core.domain.R;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.openfeign.FallbackFactory;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Component
public class XpOperationsRoleClientFallbackFactory implements FallbackFactory<XpOperationsRoleClient> {
    @Override
    public XpOperationsRoleClient create(Throwable cause) {
        return operationUserId -> {
            log.error("查询运维人员角色关联失败,operationUserId={}, err={}", operationUserId, cause.getMessage());
            return R.ok(new ArrayList<>());
        };
    }
}
xp-robotaxi-api/src/main/java/com/xiaopeng/api/feignClient/account/XpOperationsRoleClient.java
New file
@@ -0,0 +1,31 @@
package com.xiaopeng.api.feignClient.account;
import com.xiaopeng.api.factory.account.XpOperationsRoleClientFallbackFactory;
import com.xiaopeng.api.model.account.XpOperationsRole;
import com.xiaopeng.common.core.domain.R;
import com.xiaopeng.common.core.dto.ServiceNameConstantsEnum;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
/**
 * 运维人员角色关联 Feign 客户端
 */
@FeignClient(
        contextId = "XpOperationsRoleClient",
        value = ServiceNameConstantsEnum.XP_ACCOUNT_SERVICE,
        fallbackFactory = XpOperationsRoleClientFallbackFactory.class
)
public interface XpOperationsRoleClient {
    /**
     * 根据运维人员ID查询运维人员-角色关联列表
     *
     * @param operationUserId 运维人员id
     */
    @GetMapping("/inner/xp-operations-role/listByOperationUserId")
    R<List<XpOperationsRole>> listByOperationUserId(@RequestParam("operationUserId") Long operationUserId);
}
xp-robotaxi-api/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -1,5 +1,6 @@
com.xiaopeng.api.factory.operation.SysLogClientFallbackFactory
com.xiaopeng.api.factory.account.XpAppUserCouponClientFallbackFactory
com.xiaopeng.api.factory.account.XpOperationsRoleClientFallbackFactory
com.xiaopeng.api.factory.account.XpAppUserClientFallbackFactory
com.xiaopeng.api.factory.map.XpMapClientFallbackFactory
com.xiaopeng.api.factory.operation.RemoteMenuFallbackFactory
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/constant/I18nKeyConstant.java
@@ -86,4 +86,15 @@
    String CRUISE_PLAN_ADD_FAIL = "cruise.plan.add.fail";
    // 编辑巡游计划失败
    String CRUISE_PLAN_EDIT_FAIL = "cruise.plan.edit.fail";
    // 点位-运营区域ID不能为空
    String POINT_ELECTRONIC_AREA_ID_EMPTY = "point.electronic.area.id.empty";
    // 点位-运营区域不存在
    String POINT_ELECTRONIC_AREA_NOT_EXIST = "point.electronic.area.not.exist";
    // 点位-城市信息不存在
    String POINT_CITY_NOT_EXIST = "point.city.not.exist";
    // 点位-编号锁获取失败
    String POINT_NUMBER_LOCK_FAIL = "point.number.lock.fail";
    // 点位-编号解析失败
    String POINT_NUMBER_PARSE_FAIL = "point.number.parse.fail";
}
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/controller/XpPointManagementController.java
@@ -89,9 +89,7 @@
//    @Transactional
//    @ShardingSphereTransactionType(TransactionType.BASE)
    public R add(@Validated @RequestBody PointAddDto pointAddDto) {
        XpPoint xpPoint = new XpPoint();
        BeanUtils.copyProperties(pointAddDto, xpPoint);
        xpPointService.save(xpPoint);
        xpPointService.addPointWithGeneratedNumber(pointAddDto);
        return R.ok();
    }
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/mapper/XpPointMapper.java
@@ -9,6 +9,7 @@
import com.xiaopeng.api.vo.order.XpPointsListVo;
import com.xiaopeng.common.core.web.page.PageInfo;
import com.xiaopeng.map.query.PointByNameQuery;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@@ -31,4 +32,7 @@
    List<XpPointsListVo> queryPointByName(@Param("query") PointByNameQuery query);
    List<XpPoint> pageList(@Param("pageInfo") PageInfo<XpPoint> pageInfo, @Param("query") PointPageListListQuery query);
    @Select("SELECT point_number FROM xp_point WHERE point_number LIKE CONCAT(#{cityPrefix}, '-', #{bizType}, '-%') ORDER BY point_number DESC LIMIT 1")
    String selectMaxPointNumber(@Param("cityPrefix") String cityPrefix, @Param("bizType") String bizType);
}
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/service/XpPointService.java
@@ -2,6 +2,7 @@
import com.baomidou.mybatisplus.extension.service.IService;
import com.xiaopeng.api.dto.operation.PointAddDto;
import com.xiaopeng.api.dto.operation.PointMapQuery;
import com.xiaopeng.api.dto.order.HistoryEndPointsDto;
import com.xiaopeng.api.dto.order.HistoryStartPointsDto;
@@ -33,6 +34,11 @@
public interface XpPointService extends IService<XpPoint> {
    /**
     * 新增点位并根据规则生成点位编号
     */
    void addPointWithGeneratedNumber(@Valid PointAddDto pointAddDto);
    /**
     * 获取附近上车站点
     */
    NearestVo nearest(@Valid NearestDto dto);
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/service/serviceimpl/XpPointServiceImpl.java
@@ -3,10 +3,13 @@
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.xiaopeng.api.dto.operation.PointAddDto;
import com.xiaopeng.api.dto.operation.PointMapQuery;
import com.xiaopeng.api.dto.order.HistoryEndPointsDto;
import com.xiaopeng.api.dto.order.HistoryStartPointsDto;
import com.xiaopeng.api.model.operation.XpElectronicFenceArea;
import com.xiaopeng.api.model.operation.XpPoint;
import com.xiaopeng.api.model.operation.XpRegion;
import com.xiaopeng.api.model.operation.XpSystemSetting;
import com.xiaopeng.api.query.operation.PointListQuery;
import com.xiaopeng.api.query.operation.PointPageListListQuery;
@@ -15,12 +18,17 @@
import com.xiaopeng.api.vo.order.XpPointsListVo;
import com.xiaopeng.api.vo.order.XpPointsSearchVo;
import com.xiaopeng.common.core.constant.CacheConstants;
import com.xiaopeng.common.core.exception.ServiceException;
import com.xiaopeng.common.core.utils.StringUtils;
import com.xiaopeng.common.core.web.page.PageInfo;
import com.xiaopeng.common.redis.service.RedisService;
import com.xiaopeng.map.constant.I18nKeyConstant;
import com.xiaopeng.map.dto.NearestDto;
import com.xiaopeng.map.mapper.XpPointMapper;
import com.xiaopeng.map.mapper.XpRegionMapper;
import com.xiaopeng.map.mapper.XpSystemSettingMapper;
import com.xiaopeng.map.query.PointByNameQuery;
import com.xiaopeng.map.service.XpElectronicFenceAreaService;
import com.xiaopeng.map.service.XpPointService;
import com.xiaopeng.map.utils.TimeRangeChecker;
import com.xiaopeng.map.utils.holiday.HolidayUtil;
@@ -28,6 +36,12 @@
import com.xiaopeng.map.vo.NearestNextVo;
import com.xiaopeng.map.vo.NearestVo;
import com.xiaopeng.map.vo.OrderDistanceVo;
import lombok.extern.slf4j.Slf4j;
import net.sourceforge.pinyin4j.PinyinHelper;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
@@ -35,6 +49,7 @@
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
@@ -47,7 +62,13 @@
 * @since 2026-02-03
 */
@Service
@Slf4j
public class XpPointServiceImpl extends ServiceImpl<XpPointMapper, XpPoint> implements XpPointService {
    private static final String POINT_BIZ_TYPE = "PUD";
    private static final int SERIAL_WIDTH = 6;
    private static final int LOCK_WAIT_SECONDS = 5;
    private static final int LOCK_LEASE_SECONDS = 30;
    @Resource
    private XpSystemSettingMapper xpSystemSettingMapper;
@@ -58,6 +79,59 @@
    @Resource
    private HolidayUtil holidayUtil;
    @Resource
    private XpElectronicFenceAreaService xpElectronicFenceAreaService;
    @Resource
    private XpRegionMapper xpRegionMapper;
    @Autowired
    private RedissonClient redissonClient;
    @Override
    public void addPointWithGeneratedNumber(PointAddDto pointAddDto) {
        if (pointAddDto == null) {
            throw new ServiceException("参数不能为空", I18nKeyConstant.SYSTEM_EXCEPTION);
        }
        if (pointAddDto.getElectronicAreaId() == null) {
            throw new ServiceException("运营区域ID不能为空", I18nKeyConstant.POINT_ELECTRONIC_AREA_ID_EMPTY);
        }
        String cityPrefix = buildCityPrefix(pointAddDto.getElectronicAreaId());
        String lockKey = buildPointNumberLockKey(cityPrefix, POINT_BIZ_TYPE);
        RLock lock = redissonClient.getLock(lockKey);
        boolean locked = false;
        String pointNumber;
        try {
            locked = lock.tryLock(LOCK_WAIT_SECONDS, LOCK_LEASE_SECONDS, TimeUnit.SECONDS);
            if (!locked) {
                throw new ServiceException("点位编号生成繁忙,请稍后重试", I18nKeyConstant.POINT_NUMBER_LOCK_FAIL);
            }
            String maxPointNumber = this.baseMapper.selectMaxPointNumber(cityPrefix, POINT_BIZ_TYPE);
            int nextSerial = 1;
            if (StringUtils.hasText(maxPointNumber)) {
                nextSerial = parseSerialOrThrow(maxPointNumber) + 1;
            }
            pointNumber = cityPrefix + "-" + POINT_BIZ_TYPE + "-" + String.format("%0" + SERIAL_WIDTH + "d", nextSerial);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new ServiceException("点位编号生成繁忙,请稍后重试", I18nKeyConstant.POINT_NUMBER_LOCK_FAIL);
        } finally {
            try {
                if (locked && lock.isHeldByCurrentThread()) {
                    lock.unlock();
                }
            } catch (Exception e) {
                log.warn("释放点位编号生成锁失败 lockKey={}", lockKey, e);
            }
        }
        XpPoint xpPoint = new XpPoint();
        BeanUtils.copyProperties(pointAddDto, xpPoint);
        xpPoint.setPointNumber(pointNumber);
        this.save(xpPoint);
    }
    @Override
    public NearestVo nearest(NearestDto dto) {
@@ -112,6 +186,57 @@
    }
    private String buildCityPrefix(Long electronicAreaId) {
        XpElectronicFenceArea area = xpElectronicFenceAreaService.getById(electronicAreaId);
        if (area == null) {
            throw new ServiceException("运营区域不存在", I18nKeyConstant.POINT_ELECTRONIC_AREA_NOT_EXIST);
        }
        if (!StringUtils.hasText(area.getCityCode())) {
            throw new ServiceException("城市信息不存在", I18nKeyConstant.POINT_CITY_NOT_EXIST);
        }
        XpRegion region = xpRegionMapper.selectOne(new LambdaQueryWrapper<XpRegion>()
                .eq(XpRegion::getCode, area.getCityCode()));
        if (region == null || !StringUtils.hasText(region.getName())) {
            throw new ServiceException("城市信息不存在", I18nKeyConstant.POINT_CITY_NOT_EXIST);
        }
        String name = region.getName();
        String prefix = name.length() <= 2 ? name : name.substring(0, 2);
        return getPinyinInitials(prefix).toUpperCase();
    }
    private static String buildPointNumberLockKey(String cityPrefix, String bizType) {
        return "xp:point:number:" + cityPrefix + ":" + bizType;
    }
    private static String getPinyinInitials(String chinese) {
        StringBuilder pinyinInitials = new StringBuilder();
        for (char c : chinese.toCharArray()) {
            String[] pinyinArray = PinyinHelper.toHanyuPinyinStringArray(c);
            if (pinyinArray != null && pinyinArray.length > 0) {
                pinyinInitials.append(pinyinArray[0].charAt(0));
            }
        }
        return pinyinInitials.toString();
    }
    private static int parseSerialOrThrow(String pointNumber) {
        String[] parts = pointNumber.split("-");
        if (parts.length < 3) {
            throw new ServiceException("点位编号生成失败(历史编号格式异常)", I18nKeyConstant.POINT_NUMBER_PARSE_FAIL);
        }
        String serialPart = parts[parts.length - 1];
        if (serialPart.length() != SERIAL_WIDTH) {
            throw new ServiceException("点位编号生成失败(历史编号格式异常)", I18nKeyConstant.POINT_NUMBER_PARSE_FAIL);
        }
        try {
            return Integer.parseInt(serialPart);
        } catch (NumberFormatException e) {
            throw new ServiceException("点位编号生成失败(历史编号格式异常)", I18nKeyConstant.POINT_NUMBER_PARSE_FAIL);
        }
    }
    public boolean getInTime(LocalTime now,  Integer effectivePeriod, String effectiveTimePeriod){
xp-robotaxi-map-boot/src/main/resources/i18n/messages.properties
@@ -34,3 +34,8 @@
cruise.plan.vehicle.already.bound=车辆已关联其他巡游计划
cruise.plan.add.fail=新增巡游计划失败
cruise.plan.edit.fail=编辑巡游计划失败
point.electronic.area.id.empty=运营区域ID不能为空
point.electronic.area.not.exist=运营区域不存在
point.city.not.exist=城市信息不存在
point.number.lock.fail=点位编号生成繁忙,请稍后重试
point.number.parse.fail=点位编号生成失败(历史编号格式异常)
xp-robotaxi-map-boot/src/main/resources/i18n/messages_en_US.properties
@@ -27,3 +27,8 @@
cruise.plan.vehicle.duplicate=Duplicate vehicle ID
cruise.plan.vehicle.already.bound=Vehicle [{0}] has been associated with other cruising plans
cruise.plan.add.fail=The new cruise plan failed
point.electronic.area.id.empty=Electronic fence area ID cannot be empty
point.electronic.area.not.exist=Electronic fence area does not exist
point.city.not.exist=City information does not exist
point.number.lock.fail=Point number generation is busy, please try again later
point.number.parse.fail=Point number generation failed (invalid historical number format)
xp-robotaxi-map-boot/src/main/resources/i18n/messages_zh_CN.properties
@@ -27,3 +27,8 @@
cruise.plan.vehicle.duplicate=车辆ID重复
cruise.plan.vehicle.already.bound=车辆[{0}]已关联其他巡游计划
cruise.plan.add.fail=新增巡游计划失败
point.electronic.area.id.empty=运营区域ID不能为空
point.electronic.area.not.exist=运营区域不存在
point.city.not.exist=城市信息不存在
point.number.lock.fail=点位编号生成繁忙,请稍后重试
point.number.parse.fail=点位编号生成失败(历史编号格式异常)
xp-robotaxi-operation-boot/src/main/java/com/xiaopeng/operation/controller/SysMenuController.java
@@ -5,10 +5,8 @@
import com.xiaopeng.common.core.web.controller.BaseController;
import com.xiaopeng.operation.service.ISysMenuService;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.annotations.ApiParam;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.ArrayList;
@@ -46,10 +44,10 @@
     * 获取全部菜单树
     * @return
     */
    @GetMapping("/api/v1/getMenuTree")
    @GetMapping("/api/v1/getMenuTree/{type}")
    @ApiOperation(value = "获取全部菜单树", tags = {"管理后台-权限组设置"})
    public R<List<SysMenu>> getMenuTree() {
        List<SysMenu> menus = menuService.getMenuTree();
    public R<List<SysMenu>> getMenuTree(@ApiParam(name = "type", value = "1=管理端 2=运维app") @PathVariable Integer type) {
        List<SysMenu> menus = menuService.getMenuTree(type);
        return R.ok(menus);
    }
@@ -59,4 +57,10 @@
        return R.ok(menuService.getPermission());
    }
    @GetMapping("/api/v1/getAppPermission")
    @ApiOperation(value = "获取获取当前登录用户权限列表", tags = {"运维APP-个人中心"})
    public R<List<SysMenu>> getAppPermission(){
        return R.ok(menuService.getAppPermission());
    }
}
xp-robotaxi-operation-boot/src/main/java/com/xiaopeng/operation/service/ISysMenuService.java
@@ -27,11 +27,17 @@
     *获取全部菜单树
     * @return
     */
    List<SysMenu> getMenuTree();
    List<SysMenu> getMenuTree(Integer type);
    /**
     *获取获取当前登录用户菜单列表
     * @return
     */
    List<SysMenu> getPermission();
    /**
     * 获取运维人员权限列表
     * @return
     */
    List<SysMenu> getAppPermission();
}
xp-robotaxi-operation-boot/src/main/java/com/xiaopeng/operation/service/impl/SysMenuServiceImpl.java
@@ -4,8 +4,10 @@
import com.xiaopeng.api.feignClient.account.SysGroupMenuClient;
import com.xiaopeng.api.feignClient.account.SysRoleClient;
import com.xiaopeng.api.feignClient.account.SysUserRoleClient;
import com.xiaopeng.api.feignClient.account.XpOperationsRoleClient;
import com.xiaopeng.api.model.account.SysRole;
import com.xiaopeng.api.model.account.SysUserRole;
import com.xiaopeng.api.model.account.XpOperationsRole;
import com.xiaopeng.api.model.operation.SysMenu;
import com.xiaopeng.common.core.domain.R;
import com.xiaopeng.common.security.service.TokenService;
@@ -42,6 +44,9 @@
    @Autowired
    private SysGroupMenuClient sysGroupMenuClient;
    @Autowired
    private XpOperationsRoleClient xpOperationsRoleClient;
    /**
     * 根据菜单ID列表查询菜单列表
     *
@@ -59,8 +64,8 @@
    }
    @Override
    public List<SysMenu> getMenuTree() {
        List<SysMenu> all = list();
    public List<SysMenu> getMenuTree(Integer type) {
        List<SysMenu> all = lambdaQuery().eq(SysMenu::getGroupType, type).list();
        if (all == null || all.isEmpty()) {
            return Collections.emptyList();
        }
@@ -184,4 +189,139 @@
            return Collections.emptyList();
        }
    }
    @Override
    public List<SysMenu> getAppPermission() {
        // 1. 获取当前登录用户ID
        Long operationUserId = tokenService.getLoginOperationUser().getId();
        if (operationUserId == null) {
            log.warn("获取当前登录用户ID失败");
            return Collections.emptyList();
        }
        // 2. 查询运维APP菜单(group_type=2)
        List<SysMenu> appMenus = this.lambdaQuery()
                .eq(SysMenu::getGroupType, 2)
                .list();
        if (appMenus == null || appMenus.isEmpty()) {
            return Collections.emptyList();
        }
        // 3. 查询运维人员关联的角色(一个用户可多个角色)
        Set<Long> roleIds = new HashSet<>();
        try {
            R<List<XpOperationsRole>> rolesResult = xpOperationsRoleClient.listByOperationUserId(operationUserId);
            if (rolesResult != null && rolesResult.getData() != null) {
                for (XpOperationsRole r : rolesResult.getData()) {
                    if (r != null && r.getRoleId() != null) {
                        roleIds.add(r.getRoleId());
                    }
                }
            }
        } catch (Exception e) {
            log.error("查询运维人员角色关联异常, operationUserId={}", operationUserId, e);
        }
        // 4. 通过 role -> groupId -> group_menu 获取允许的菜单ID集合
        Set<Long> allowedMenuIds = new HashSet<>();
        if (!roleIds.isEmpty()) {
            Set<Long> groupIds = new HashSet<>();
            for (Long roleId : roleIds) {
                try {
                    R<SysRole> roleResult = sysRoleClient.getRoleById(roleId);
                    if (roleResult != null && roleResult.getData() != null && roleResult.getData().getGroupId() != null) {
                        groupIds.add(roleResult.getData().getGroupId());
                    }
                } catch (Exception e) {
                    log.error("查询角色信息异常, roleId={}", roleId, e);
                }
            }
            for (Long groupId : groupIds) {
                try {
                    R<List<Long>> menuIdsResult = sysGroupMenuClient.selectMenuIdsByGroupId(groupId);
                    if (menuIdsResult != null && menuIdsResult.getData() != null) {
                        allowedMenuIds.addAll(menuIdsResult.getData());
                    }
                } catch (Exception e) {
                    log.error("查询权限组菜单ID异常, groupId={}", groupId, e);
                }
            }
        }
        // 5. 仅保留运维APP的菜单ID,避免无关ID影响 visible
        Set<Long> appMenuIds = appMenus.stream()
                .filter(Objects::nonNull)
                .map(SysMenu::getId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        allowedMenuIds.retainAll(appMenuIds);
        // 6. 组装树形结构,并用 visible 体现权限可见性(0可见 1不可见)
        return buildAppMenuTreeWithVisible(appMenus, allowedMenuIds);
    }
    private List<SysMenu> buildAppMenuTreeWithVisible(List<SysMenu> menus, Set<Long> allowedMenuIds) {
        Map<Long, SysMenu> idMap = new HashMap<>();
        for (SysMenu m : menus) {
            if (m != null && m.getId() != null) {
                idMap.put(m.getId(), m);
            }
        }
        Map<Long, List<SysMenu>> childrenMap = menus.stream()
                .filter(Objects::nonNull)
                .filter(m -> m.getId() != null)
                .collect(Collectors.groupingBy(m -> {
                    Long pid = m.getParentId();
                    return pid == null ? 0L : pid;
                }));
        // 根节点:parentId 为 0/null,或父节点不在当前 group_type=2 集合内
        List<SysMenu> roots = menus.stream()
                .filter(Objects::nonNull)
                .filter(m -> m.getId() != null)
                .filter(m -> {
                    Long pid = m.getParentId();
                    if (pid == null || pid == 0L) {
                        return true;
                    }
                    return !idMap.containsKey(pid);
                })
                .sorted(Comparator.comparing(SysMenu::getOrderNum, Comparator.nullsLast(Integer::compareTo)))
                .collect(Collectors.toList());
        for (SysMenu root : roots) {
            fillChildrenAndVisible(root, childrenMap, allowedMenuIds, new HashSet<>());
        }
        return roots;
    }
    private boolean fillChildrenAndVisible(SysMenu node, Map<Long, List<SysMenu>> childrenMap, Set<Long> allowedMenuIds, Set<Long> path) {
        Long id = node.getId();
        if (id != null && !path.add(id)) {
            boolean visible = allowedMenuIds.contains(id);
            node.setChildren(Collections.emptyList());
            node.setVisible(visible ? "0" : "1");
            return visible;
        }
        List<SysMenu> children = childrenMap.getOrDefault(id == null ? 0L : id, Collections.emptyList()).stream()
                .filter(Objects::nonNull)
                .sorted(Comparator.comparing(SysMenu::getOrderNum, Comparator.nullsLast(Integer::compareTo)))
                .collect(Collectors.toList());
        boolean anyChildVisible = false;
        for (SysMenu child : children) {
            anyChildVisible |= fillChildrenAndVisible(child, childrenMap, allowedMenuIds, path);
        }
        node.setChildren(children);
        boolean selfVisible = id != null && allowedMenuIds.contains(id);
        boolean visible = selfVisible || anyChildVisible;
        node.setVisible(visible ? "0" : "1");
        if (id != null) {
            path.remove(id);
        }
        return visible;
    }
}