系统菜单增量导入方案的技术解析与创新实践

27 阅读4分钟

一、项目背景与痛点洞察

在升级菜单系统时,尝试在现有基础上添加新菜单项时,如果直接导入全部菜单数据,系统会提示“重复数据错误”,这让整个升级过程变得不那么顺畅。特别是当产品经理提出了一种包含五层嵌套结构的复杂菜单设计后,手动创建这些菜单显得尤为繁琐,传统的全量导入方式也显得不太合适了。为了克服这些问题,主要是两个方面:一是如何有效避免因重复数据导致的问题;二是怎样更好地管理和呈现复杂的树状菜单结构。

graph TD
  A[传统方案] --> B[手工维护效率低]
  A --> C[层级越界风险]
  A --> D[数据一致性难保证]

为此,采取了几项创新措施来优化流程:

  • 动态冲突检测:通过智能算法自动识别并妥善处理可能存在的重复信息。
  • 树形结构解析:自动生成菜单间的层级关系图谱,简化了构建过程。
  • 事务化操作:采用批量处理技术确保每次更新都能保持数据的一致性和完整性。

二、技术解决方案全景

核心架构设计:

flowchart LR
    subgraph 问题域
        P1[数据冲突] --> S1[智能去重]
        P2[层级维护] --> S2[结构解析]
        P3[数据完整] --> S3[事务保障]
    end
    subgraph 方案层
        S1 --> D1[动态冲突检测]
        S2 --> D2[树形构建引擎]
        S3 --> D3[批处理事务]
    end

sequenceDiagram
    participant Excel
    participant 校验引擎
    participant 清洗中心
    participant 增量识别
    participant 结构构建
    participant 数据库
    
    Excel->>校验引擎: 上传原始数据
    校验引擎->>清洗中心: 去重/补全
    清洗中心->>增量识别: 数据分类
    增量识别->>结构构建: 生成树形
    结构构建->>数据库: 事务写入
    数据库-->>Excel: 反馈结果

三、项目代码

/**
 * 菜单配置批量导入处理器(支持增量更新)
 * @param excelFile 上传的菜单配置文件
 * @param productId 所属产品线标识
 */
@Transactional(rollbackFor = Exception.class)
public String importMenuExcelNew(MultipartFile multipartFile, String productId) throws Exception {
    // 1. 读取并校验Excel数据
    ExcelImportResult<SysMenuExcelVO> excelImportResult = readAndValidateExcel(multipartFile);

    // 2. 处理导入数据 判断数据数量和格式,父级和子级的状态
    List<SysMenuExcelVO> processedMenuList = processImportData(excelImportResult);

    // 3. 收集并处理错误信息
    List<String> errorMessages = collectErrorMessages(processedMenuList);
    if (CollectionUtils.isNotEmpty(errorMessages)) {
        return String.join("", errorMessages);
    }

    // 4. 业务校验
    performBusinessValidations(processedMenuList, errorMessages);

    // 5. 数据分拣(新增/更新)
    // 增量操作,把code相同的做更新
    List<SysMenuExcelVO> sysMenuListToAdd = new ArrayList<>();
    List<SysMenuExcelVO> sysMenuListToUpdate = new ArrayList<>();
    User currentUser = getCurrentUser(User.class);
    // 不使用原有导入的id  使用新的id
    Map<String, String> nameIdsMap = new HashMap<>();
    checkExistConflict(processedMenuList, productId, currentUser, sysMenuListToAdd, sysMenuListToUpdate, nameIdsMap);
    if (CollectionUtils.isNotEmpty(errorMessages)) {
        return String.join("", errorMessages);
    }
    // 6. 执行数据库操作
    executeDatabaseOperations(processedMenuList,sysMenuListToUpdate, sysMenuListToAdd, nameIdsMap, currentUser, productId);

    // 7. 处理缓存
    // 删除租户菜单树缓存
    redisUtil.delByPattern(UserCenterConstant.TENANT_MENU_PERMISSION + "*");
    // 刷新缓存对应的产品菜单
    sysMenuCacheService.refreshSysMenuByProductIds(productId);

    return UserCenterConstant.IMPORT_SUCCESS;
}
// 数据分拣(新增/更新)
private void checkExistConflict(List<SysMenuExcelVO> menuListToImport, String productId, User currentUser, List<SysMenuExcelVO> sysMenuListToAdd, List<SysMenuExcelVO> sysMenuListToUpdate, Map<String, String> nameIdsMap) {
    List<SysMenu> sysMenuListDB = this.lambdaQuery()
    .eq(SysMenu::getProductId, productId)
    .eq(SysMenu::getTenantId,currentUser.getTenantId())
    .list();
    Map<String, SysMenu> existingUserMap = sysMenuListDB.stream().collect(Collectors.toMap(SysMenu::getButtonCode, Function.identity()));
    // 如果code重复,添加到更新列表 不重复添加到新增列表
    classify(menuListToImport, existingUserMap, sysMenuListToAdd, sysMenuListToUpdate, nameIdsMap);
}
    /**
     *增量识别算法
     * 分类处理新增/更新用户
     */
private void classify(List<SysMenuExcelVO> menuListToImport,
                      Map<String, SysMenu> existingUserMap,
                      List<SysMenuExcelVO> toAdd,
                      List<SysMenuExcelVO> toUpdate,
                      Map<String, String> nameIdMap) {
    menuListToImport.forEach(menu -> {
        SysMenu existing = existingUserMap.get(menu.getButtonCode());
        if (existing != null) {
            toUpdate.add(menu);
            nameIdMap.put(menu.getButtonCode(), existing.getId());
        } else {
            toAdd.add(menu);
        }
    });
}
   
    // 智能分片保存
    private void partitionAndSave(List<SysMenu> menus) {
        int maxBatchSize = UserCenterConstant.MAX_MENUS;
        int total = menus.size();

        for (int i=0; i<total; i+=maxBatchSize) {
            int end = Math.min(i + maxBatchSize, total);
            List<SysMenu> batch = menus.subList(i, end);

            saveBatch(batch);

            // 释放内存(针对超大数据集)
            if (total > 1000) {
                batch.clear();
            }
        }
    }
树形结构构建
     /**
     * 处理菜单项ID重构并保持父子关系
     * 1. 预生成新ID映射表解决父子引用问题
     * 2. 保留原始树形结构的同时替换所有节点ID
     * @param menuList 需要处理的菜单项集合,需包含id/parentId字段
     *                  要求parentId为0表示根节点,其他情况需与已有id对应
     */
 private void batchCreateMenus(List<SysMenuExcelVO> processedMenuListList,List<SysMenuExcelVO> creates, User currentUser, String productId) {

    
        // 预生成所有新ID,保持之前的父子关系,解决父子引用问题
        Map<String, String> idMap = new HashMap<>();
        // 为每个父节点预生成新ID,建立旧父ID到新父ID的映射
        menuList.forEach(item -> {
            idMap.put(item.getParentId(), IdWorker.getIdStr());
        });
        // 根节点固定
        idMap.put("0", "0");
        
        Date currentDate = new Date();
        // 遍历处理所有菜单项,替换父子ID
        menuList.stream().forEach(item -> {
            // 替换父节点ID为预生成的新ID
            item.setParentId(idMap.get(item.getParentId()));
            
            // 处理当前节点ID替换逻辑
            if (StrUtil.isNotBlank(idMap.get(item.getId()))) {
                // 当前节点是其他节点的父节点时,使用预生成的新ID
                item.setId(idMap.get(item.getId()));
            } else {
                // 叶子节点生成全新ID
                item.setId(IdWorker.getIdStr());
            }
            item.setxxx(xxx);

        });
        if (CollectionUtil.isNotEmpty(menuList)) {
            // 构建菜单层级
            List<SysMenu> totalSysMenu = BeanCopyUtil.copyPropertiesOfList(processedMenuListList, SysMenu.class);
            // 构造parentIds和level 传入全量数据防止死循环
            totalSysMenu = buildMenuTree(totalSysMenu);
            Map<String, SysMenu> totalSysMenuMap = totalSysMenu.stream().collect(Collectors.toMap(SysMenu::getButtonCode, item -> item));
            // 从全量数据拿到parentIds和level
            menuList.forEach(item -> {
                if(totalSysMenuMap.containsKey(item.getButtonCode())){
                    item.setParentIds(totalSysMenuMap.get(item.getButtonCode()).getParentIds());
                    item.setWlevel(totalSysMenuMap.get(item.getButtonCode()).getWlevel());
                }
            });
            // 智能分片保存
            partitionAndSave(menuList);
        }
    }
void buildMenuTree(List<SysMenu> menus) {
    menus.forEach(menu -> {
        LinkedList<String> parentChain = new LinkedList<>();
        SysMenu current = menu;

        while (!"0".equals(current.getParentId())) {
            SysMenu parent = findParent(current.getParentId());
            parentChain.addFirst(parent.getId());
            current = parent;
        }

        menu.setParentIds(String.join(",", parentChain));
        menu.setLevel(parentChain.size());
    });
}

四、性能优化实践

  1. 分片批处理技术
// 智能分片保存
private void partitionAndSave(List<SysMenu> menus) {
    int maxBatchSize = UserCenterConstant.MAX_MENUS;
    int total = menus.size();

    for (int i=0; i<total; i+=maxBatchSize) {
        int end = Math.min(i + maxBatchSize, total);
        List<SysMenu> batch = menus.subList(i, end);

        // 每个批次独立事务
        saveBatchInNewTransaction(batch); 
    }
}

@Transactional(propagation = Propagation.REQUIRES_NEW) // 新开事务
public void saveBatchInNewTransaction(List<SysMenu> batch) {
    saveBatch(batch);
}
  1. id生成
// 辅助方法 ID预生成(保证线程安全)
private synchronized void generateNewId(SysMenu menu) {
    menu.setId(IdWorker.getIdStr());
}
  1. 缓存预热策略
@Async
public void preheatCache(String productId) {
List<SysMenu> menus = loadFromDB(productId);
redisTemplate.opsForValue().set(buildCacheKey(productId), 
                                buildMenuTree(menus), 
                                30, TimeUnit.MINUTES);
}

五、演进方向

  1. 版本化控制:支持菜单变更历史追溯
  2. 可视化编排:提供图形化菜单结构编辑器
  3. 智能预测:基于历史数据的导入策略推荐