From ce841ddf85c48bae32f9e3068403fdfe98ac8711 Mon Sep 17 00:00:00 2001
From: zzw <228544128@qq.com>
Date: 星期三, 07 十月 2026 12:35:09 +0800
Subject: [PATCH] feat: add extension defaults for fault reporting

---
 emergency-repair-server/src/main/java/com/emergencyrepair/system/service/SystemService.java | 1089 ++++++++++++++++++++++++++++++++++++++++++++++++++++++---
 1 files changed, 1,027 insertions(+), 62 deletions(-)

diff --git a/emergency-repair-server/src/main/java/com/emergencyrepair/system/service/SystemService.java b/emergency-repair-server/src/main/java/com/emergencyrepair/system/service/SystemService.java
index 7d8f9b5..e14467e 100644
--- a/emergency-repair-server/src/main/java/com/emergencyrepair/system/service/SystemService.java
+++ b/emergency-repair-server/src/main/java/com/emergencyrepair/system/service/SystemService.java
@@ -1,41 +1,55 @@
 package com.emergencyrepair.system.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.IdWorker;
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.emergencyrepair.business.entity.BaseEquipment;
-import com.emergencyrepair.business.entity.Contractor;
 import com.emergencyrepair.business.entity.EngineeringMajor;
 import com.emergencyrepair.business.entity.TeamMajor;
 import com.emergencyrepair.business.entity.ProductNode;
 import com.emergencyrepair.business.mapper.BaseEquipmentMapper;
-import com.emergencyrepair.business.mapper.ContractorMapper;
 import com.emergencyrepair.business.mapper.EngineeringMajorMapper;
 import com.emergencyrepair.business.mapper.ProductNodeMapper;
 import com.emergencyrepair.business.mapper.TeamMajorMapper;
 import com.emergencyrepair.common.BusinessException;
+import com.emergencyrepair.common.audit.CurrentUserContext;
 import com.emergencyrepair.system.entity.SysMenu;
+import com.emergencyrepair.system.entity.SysErrorLog;
+import com.emergencyrepair.system.entity.SysLoginLog;
+import com.emergencyrepair.system.entity.SysOperationLog;
 import com.emergencyrepair.system.entity.SysRole;
 import com.emergencyrepair.system.entity.SysRoleMenu;
 import com.emergencyrepair.system.entity.SysRoleUser;
 import com.emergencyrepair.system.entity.SysTenantMenu;
 import com.emergencyrepair.system.mapper.SysMenuMapper;
+import com.emergencyrepair.system.mapper.SysErrorLogMapper;
+import com.emergencyrepair.system.mapper.SysLoginLogMapper;
+import com.emergencyrepair.system.mapper.SysOperationLogMapper;
 import com.emergencyrepair.system.mapper.SysRoleMapper;
 import com.emergencyrepair.system.mapper.SysRoleMenuMapper;
 import com.emergencyrepair.system.mapper.SysRoleUserMapper;
 import com.emergencyrepair.system.mapper.SysTenantMenuMapper;
 import lombok.RequiredArgsConstructor;
+import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.StringUtils;
 
 import java.time.LocalDateTime;
+import java.time.LocalDate;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.LinkedHashMap;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.stream.Collectors;
 
 @Service
@@ -46,14 +60,29 @@
     private final SysRoleMenuMapper roleMenuMapper;
     private final SysTenantMenuMapper tenantMenuMapper;
     private final SysRoleUserMapper roleUserMapper;
-    private final ContractorMapper contractorMapper;
+    private final SysLoginLogMapper loginLogMapper;
+    private final SysOperationLogMapper operationLogMapper;
+    private final SysErrorLogMapper errorLogMapper;
+    private final ButtonPermissionCatalog buttonPermissionCatalog;
+    private final JdbcTemplate jdbcTemplate;
     private final EngineeringMajorMapper engineeringMajorMapper;
     private final TeamMajorMapper teamMajorMapper;
     private final ProductNodeMapper productNodeMapper;
     private final BaseEquipmentMapper baseEquipmentMapper;
 
     public List<SysMenu> menuTree() {
-        List<SysMenu> menus = listMenus();
+        // Buttons are permission points, not navigable nodes.
+        List<SysMenu> navigable = listMenus().stream()
+                .filter(menu -> menu.getMenuType() == null || menu.getMenuType() != 2)
+                .collect(Collectors.toList());
+        return buildTree(navigable);
+    }
+
+    public List<SysMenu> menuTreeWithButtons() {
+        return buildTree(listMenus());
+    }
+
+    private List<SysMenu> buildTree(List<SysMenu> menus) {
         Map<Long, SysMenu> map = menus.stream().collect(Collectors.toMap(SysMenu::getId, item -> item));
         List<SysMenu> roots = new ArrayList<>();
         for (SysMenu menu : menus) {
@@ -76,6 +105,35 @@
         if (!StringUtils.hasText(menu.getName())) {
             throw new BusinessException("鑿滃崟鍚嶇О涓嶈兘涓虹┖");
         }
+        if (menu.getMenuType() == null) {
+            menu.setMenuType(menu.getId() == null ? 1 : menu.getMenuType());
+        }
+        boolean isRoot = menu.getPid() == null || menu.getPid() == 0L;
+        if (menu.getId() != null && !isRoot && menu.getId().equals(menu.getPid())) {
+            throw new BusinessException("涓婄骇鑿滃崟涓嶈兘閫夋嫨褰撳墠鑿滃崟");
+        }
+        SysMenu parent = null;
+        if (!isRoot) {
+            parent = menuMapper.selectById(menu.getPid());
+            if (parent == null) {
+                throw new BusinessException("涓婄骇鑿滃崟涓嶅瓨鍦�");
+            }
+            if (menu.getId() != null && isMenuDescendant(menu.getPid(), menu.getId())) {
+                throw new BusinessException("涓婄骇鑿滃崟涓嶈兘閫夋嫨褰撳墠鑿滃崟鐨勪笅绾�");
+            }
+        }
+        if (menu.getMenuType() == 2) {
+            if (parent == null || parent.getMenuType() == null || parent.getMenuType() != 1) {
+                throw new BusinessException("鎸夐挳绫诲瀷鍙兘寤哄湪鑿滃崟绫诲瀷涓嬬骇");
+            }
+            if (!StringUtils.hasText(menu.getPermissions())) {
+                throw new BusinessException("鎸夐挳鏉冮檺鏍囪瘑涓嶈兘涓虹┖");
+            }
+            menu.setUrl(null);
+            menu.setIcon(null);
+        } else if (parent != null && parent.getMenuType() != null && parent.getMenuType() == 2) {
+            throw new BusinessException("鎸夐挳绫诲瀷涓嬬骇涓嶈兘鍐嶅缓鑿滃崟");
+        }
         LocalDateTime now = LocalDateTime.now();
         menu.setUpdateTime(now);
         if (menu.getId() == null) {
@@ -91,6 +149,19 @@
             menuMapper.updateById(menu);
         }
         return menuMapper.selectById(menu.getId());
+    }
+
+    private boolean isMenuDescendant(Long candidateId, Long ancestorId) {
+        Long cursor = candidateId;
+        int guard = 0;
+        while (cursor != null && cursor != 0L && guard++ < 64) {
+            if (cursor.equals(ancestorId)) {
+                return true;
+            }
+            SysMenu current = menuMapper.selectById(cursor);
+            cursor = current == null ? null : current.getPid();
+        }
+        return false;
     }
 
     @Transactional
@@ -131,30 +202,27 @@
         roleUserMapper.delete(Wrappers.<SysRoleUser>lambdaQuery().eq(SysRoleUser::getRoleId, id));
     }
 
-    public List<Contractor> listContractors() {
-        return contractorMapper.selectList(Wrappers.<Contractor>lambdaQuery()
-                .orderByAsc(Contractor::getSortNo).orderByAsc(Contractor::getId));
-    }
-
-    @Transactional
-    public Contractor saveContractor(Contractor contractor) {
-        if (!StringUtils.hasText(contractor.getName())) {
-            throw new BusinessException("鎵夸慨鍗曚綅鍚嶇О涓嶈兘涓虹┖");
-        }
-        LocalDateTime now = LocalDateTime.now();
-        contractor.setUpdateTime(now);
-        if (contractor.getId() == null) {
-            contractor.setCreateTime(now);
-            contractorMapper.insert(contractor);
-        } else {
-            contractorMapper.updateById(contractor);
-        }
-        return contractorMapper.selectById(contractor.getId());
-    }
-
-    @Transactional
-    public void deleteContractor(Long id) {
-        contractorMapper.deleteById(id);
+    /**
+     * Contractor units are maintained as department records with DEPT_TYPE='cj'.
+     */
+    public List<Map<String, Object>> listContractors() {
+        return jdbcTemplate.query(
+                "SELECT DEPT_ID, DEPT_NAME, ORDER_NUM, LEADER, PHONE, STATUS, DEPT_CODE FROM CSICZB_ZB.SYS_DEPT "
+                        + "WHERE DEPT_TYPE = 'cj' AND (DEL_FLAG = '0' OR DEL_FLAG IS NULL) ORDER BY ORDER_NUM, DEPT_ID",
+                (rs, rowNum) -> {
+                    Map<String, Object> row = new LinkedHashMap<>();
+                    row.put("id", String.valueOf(rs.getLong("DEPT_ID")));
+                    row.put("name", rs.getString("DEPT_NAME"));
+                    row.put("shortName", null);
+                    row.put("nature", "鎵夸慨鍗曚綅");
+                    row.put("sortNo", rs.getObject("ORDER_NUM"));
+                    row.put("contact", rs.getString("LEADER"));
+                    row.put("phone", rs.getString("PHONE"));
+                    row.put("status", rs.getString("STATUS"));
+                    row.put("deptCode", rs.getString("DEPT_CODE"));
+                    row.put("dishonest", 0);
+                    return row;
+                });
     }
 
     public List<EngineeringMajor> listEngineeringMajors() {
@@ -201,12 +269,12 @@
     @Transactional
     public TeamMajor saveTeamMajor(TeamMajor major) {
         if (major == null || !StringUtils.hasText(major.getName())) {
-            throw new BusinessException("T闃熶笓涓氬悕绉颁笉鑳戒负绌�");
+            throw new BusinessException("rk_T闃熶笓涓氬悕绉颁笉鑳戒负绌�");
         }
         String name = major.getName().trim();
         String category = StringUtils.hasText(major.getCategory()) ? major.getCategory().trim().toUpperCase() : "";
         if (!"A".equals(category) && !"B".equals(category)) {
-            throw new BusinessException("T闃熶笓涓氬垎绫诲彧鑳芥槸A鎴朆");
+            throw new BusinessException("rk_T闃熶笓涓氬垎绫诲彧鑳芥槸A鎴朆");
         }
         LambdaQueryWrapper<TeamMajor> duplicate = Wrappers.<TeamMajor>lambdaQuery()
                 .eq(TeamMajor::getName, name);
@@ -214,7 +282,7 @@
             duplicate.ne(TeamMajor::getId, major.getId());
         }
         if (teamMajorMapper.selectCount(duplicate) > 0) {
-            throw new BusinessException("T闃熶笓涓氬悕绉板凡瀛樺湪锛�" + name);
+            throw new BusinessException("rk_T闃熶笓涓氬悕绉板凡瀛樺湪锛�" + name);
         }
         LocalDateTime now = LocalDateTime.now();
         major.setName(name);
@@ -244,9 +312,161 @@
         throw new BusinessException("鏈煡鏍戠被鍨�");
     }
 
+    public List<Map<String, Object>> selectedProductNodes(List<Long> ids) {
+        if (ids == null || ids.isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<ProductNode> source = productNodeMapper.selectList(Wrappers.<ProductNode>lambdaQuery()
+                .in(ProductNode::getId, ids));
+        Map<Long, ProductNode> unique = uniqueProductNodes(source);
+        List<Map<String, Object>> result = new ArrayList<>();
+        Set<Long> seen = new HashSet<>();
+        for (Long id : ids) {
+            ProductNode node = unique.get(id);
+            if (node == null || !seen.add(id)) {
+                continue;
+            }
+            List<ProductNode> path = productPath(node);
+            Map<String, Object> row = mapProduct(node, Collections.emptySet());
+            row.put("pathNames", path.stream().map(ProductNode::getName).collect(Collectors.toList()));
+            row.put("parentPathNames", path.stream()
+                    .filter(item -> item != node)
+                    .filter(item -> item.getNodeLevel() != null && item.getNodeLevel() > 2)
+                    .map(ProductNode::getName)
+                    .collect(Collectors.toList()));
+            ProductNode xNode = path.stream()
+                    .filter(item -> item.getNodeLevel() != null && item.getNodeLevel() == 2)
+                    .findFirst().orElse(null);
+            row.put("xNo", xNode == null ? null : xNode.getName());
+            result.add(row);
+        }
+        return result;
+    }
+
+    private List<Map<String, Object>> mapProductTreeNodes(List<ProductNode> source, Set<Long> forcedBranchIds) {
+        Map<Long, ProductNode> unique = uniqueProductNodes(source);
+        List<ProductNode> nodes = sortProductNodes(unique.values());
+        Set<Long> branchIds = new HashSet<>(forcedBranchIds);
+        if (!unique.isEmpty()) {
+            List<ProductNode> childRows = productNodeMapper.selectList(Wrappers.<ProductNode>lambdaQuery()
+                    .select(ProductNode::getParentId, ProductNode::getIsDelete)
+                    .in(ProductNode::getParentId, unique.keySet()));
+            for (ProductNode child : childRows) {
+                if (!"1".equals(child.getIsDelete()) && child.getParentId() != null) {
+                    branchIds.add(child.getParentId());
+                }
+            }
+        }
+        List<Map<String, Object>> rows = new ArrayList<>();
+        for (ProductNode node : nodes) {
+            rows.add(mapProduct(node, branchIds));
+        }
+        return rows;
+    }
+
+    private Map<Long, ProductNode> uniqueProductNodes(Collection<ProductNode> source) {
+        Map<Long, ProductNode> unique = new LinkedHashMap<>();
+        if (source == null) {
+            return unique;
+        }
+        for (ProductNode node : source) {
+            if (node.getId() == null || "1".equals(node.getIsDelete())) {
+                continue;
+            }
+            unique.putIfAbsent(node.getId(), node);
+        }
+        return unique;
+    }
+
+    private List<ProductNode> sortProductNodes(Collection<ProductNode> source) {
+        List<ProductNode> nodes = new ArrayList<>(source);
+        nodes.sort(Comparator.comparingInt(this::productNodeLevel)
+                .thenComparing(ProductNode::getSortNo, Comparator.nullsLast(Integer::compareTo))
+                .thenComparing(ProductNode::getId, Comparator.nullsLast(Long::compareTo)));
+        return nodes;
+    }
+
+    private ProductNode findProductRoot(ProductNode node) {
+        ProductNode current = node;
+        Set<Long> visited = new HashSet<>();
+        while (current != null && current.getParentId() != null && current.getParentId() != 0L
+                && visited.add(current.getId())) {
+            current = findProductNode(current.getParentId());
+        }
+        return current;
+    }
+
+    private List<ProductNode> productPath(ProductNode node) {
+        List<ProductNode> reversed = new ArrayList<>();
+        Set<Long> visited = new HashSet<>();
+        ProductNode current = node;
+        while (current != null && visited.add(current.getId())) {
+            reversed.add(current);
+            if (current.getParentId() == null || current.getParentId() == 0L) {
+                break;
+            }
+            current = findProductNode(current.getParentId());
+        }
+        Collections.reverse(reversed);
+        return reversed;
+    }
+
+    public List<Map<String, Object>> productTreeForXNo(String xNo) {
+        if (!StringUtils.hasText(xNo)) {
+            return productTreeRoots();
+        }
+        List<ProductNode> sideNodes = productNodeMapper.selectList(Wrappers.<ProductNode>lambdaQuery()
+                .in(ProductNode::getLayer, Arrays.asList("side", "SIDE"))
+                .eq(ProductNode::getName, xNo.trim())
+                .orderByAsc(ProductNode::getSortNo).orderByAsc(ProductNode::getId));
+        return mapProductTreeNodes(sideNodes, Collections.emptySet());
+    }
+
+    public List<String> productXNumbers() {
+        List<ProductNode> nodes = productNodeMapper.selectList(Wrappers.<ProductNode>lambdaQuery()
+                .in(ProductNode::getLayer, Arrays.asList("side", "SIDE"))
+                .orderByAsc(ProductNode::getSortNo).orderByAsc(ProductNode::getId));
+        return nodes.stream()
+                .filter(node -> !"1".equals(node.getIsDelete()))
+                .map(ProductNode::getName)
+                .filter(StringUtils::hasText)
+                .map(String::trim)
+                .distinct()
+                .sorted(String.CASE_INSENSITIVE_ORDER)
+                .collect(Collectors.toList());
+    }
+
+    public List<Map<String, Object>> productTreeRoots() {
+        List<ProductNode> nodes = productNodeMapper.selectList(Wrappers.<ProductNode>lambdaQuery()
+                .and(wrapper -> wrapper.isNull(ProductNode::getParentId).or().eq(ProductNode::getParentId, 0L))
+                .orderByAsc(ProductNode::getSortNo).orderByAsc(ProductNode::getId));
+        return mapProductTreeNodes(nodes, Collections.emptySet());
+    }
+
+    public List<Map<String, Object>> productTreeChildren(Long parentId) {
+        if (parentId == null || parentId == 0L) {
+            return productTreeRoots();
+        }
+        List<ProductNode> nodes = productNodeMapper.selectList(Wrappers.<ProductNode>lambdaQuery()
+                .eq(ProductNode::getParentId, parentId)
+                .orderByAsc(ProductNode::getSortNo).orderByAsc(ProductNode::getId));
+        return mapProductTreeNodes(nodes, Collections.emptySet());
+    }
+
     public List<Map<String, Object>> productTree() {
         List<ProductNode> nodes = productNodeMapper.selectList(Wrappers.<ProductNode>lambdaQuery()
-                .orderByAsc(ProductNode::getNodeLevel).orderByAsc(ProductNode::getSortNo));
+                .orderByAsc(ProductNode::getId).orderByAsc(ProductNode::getSortNo));
+        Map<Long, ProductNode> unique = new LinkedHashMap<>();
+        for (ProductNode node : nodes) {
+            if (node.getId() == null || "1".equals(node.getIsDelete())) {
+                continue;
+            }
+            unique.putIfAbsent(node.getId(), node);
+        }
+        nodes = new ArrayList<>(unique.values());
+        nodes.sort(Comparator.comparingInt(this::productNodeLevel)
+                .thenComparing(ProductNode::getSortNo, Comparator.nullsLast(Integer::compareTo))
+                .thenComparing(ProductNode::getId, Comparator.nullsLast(Long::compareTo)));
         Map<Long, Map<String, Object>> map = new LinkedHashMap<>();
         for (ProductNode node : nodes) {
             map.put(node.getId(), mapProduct(node));
@@ -284,24 +504,48 @@
 
     @Transactional
     public ProductNode saveProductNode(ProductNode node) {
-        if (!StringUtils.hasText(node.getName()) || node.getNodeLevel() == null) {
-            throw new BusinessException("鑺傜偣鍚嶇О鍜屽眰绾т笉鑳戒负绌�");
+        if (!StringUtils.hasText(node.getName())) {
+            throw new BusinessException("鑺傜偣鍚嶇О涓嶈兘涓虹┖");
         }
         LocalDateTime now = LocalDateTime.now();
-        node.setUpdateTime(now);
+        String layer = resolveProductLayer(node);
+        node.setLayer(layer);
+        node.setConnectGroup(buildConnectGroup(node.getParentId()));
+
         if (node.getId() == null) {
+            node.setId(IdWorker.getId());
+            node.setRecordId(String.valueOf(node.getId()));
+            node.setProject(StringUtils.hasText(node.getProject()) ? node.getProject().trim() : "0");
+            node.setSortNo(node.getSortNo() == null ? 1 : node.getSortNo());
+            node.setIsDelete("0");
+            node.setCreateBy(StringUtils.hasText(node.getCreateBy()) ? node.getCreateBy().trim() : "system");
             node.setCreateTime(now);
             productNodeMapper.insert(node);
         } else {
+            ProductNode current = findProductNode(node.getId());
+            if (current == null) {
+                throw new BusinessException("鑺傜偣涓嶅瓨鍦�");
+            }
+            node.setRecordId(current.getRecordId());
+            node.setProject(StringUtils.hasText(current.getProject()) ? current.getProject() : "0");
+            node.setIsDelete(StringUtils.hasText(current.getIsDelete()) ? current.getIsDelete() : "0");
+            node.setCreateBy(current.getCreateBy());
+            node.setCreateTime(current.getCreateTime());
             productNodeMapper.updateById(node);
         }
-        return productNodeMapper.selectById(node.getId());
+        return findProductNode(node.getId());
     }
 
     @Transactional
     public void deleteProductNode(Long id) {
-        assertNoChildren(productNodeMapper.selectCount(Wrappers.<ProductNode>lambdaQuery().eq(ProductNode::getParentId, id)));
-        productNodeMapper.deleteById(id);
+        assertNoChildren(productNodeMapper.selectCount(Wrappers.<ProductNode>lambdaQuery()
+                .eq(ProductNode::getParentId, id).ne(ProductNode::getIsDelete, "1")));
+        ProductNode current = findProductNode(id);
+        if (current == null) {
+            throw new BusinessException("鑺傜偣涓嶅瓨鍦�");
+        }
+        current.setIsDelete("1");
+        productNodeMapper.updateById(current);
     }
 
     @Transactional
@@ -326,45 +570,716 @@
         baseEquipmentMapper.deleteById(id);
     }
 
-    public List<Map<String, Object>> tenants() {
-        return Collections.singletonList(row("id", 1L, "name", "涓存姠淇鎴�"));
-    }
-
-    public Map<String, Object> menuPermissions(Long roleId, Long tenantId) {
-        List<Long> roleMenuIds = roleMenuMapper.selectList(Wrappers.<SysRoleMenu>lambdaQuery()
-                        .eq(roleId != null, SysRoleMenu::getRoleId, roleId))
-                .stream().map(SysRoleMenu::getMenuId).collect(Collectors.toList());
-        List<Long> tenantMenuIds = tenantMenuMapper.selectList(Wrappers.<SysTenantMenu>lambdaQuery()
-                        .eq(tenantId != null, SysTenantMenu::getTenantId, tenantId))
-                .stream().map(SysTenantMenu::getMenuId).collect(Collectors.toList());
-        List<Long> effective = roleMenuIds.stream().filter(tenantMenuIds::contains).collect(Collectors.toList());
+    public Map<String, Object> menuPermissions(Long roleId) {
+        if (roleId == null) {
+            throw new BusinessException("璇烽�夋嫨瑙掕壊");
+        }
+        List<String> roleMenuIds = roleMenuMapper.selectList(Wrappers.<SysRoleMenu>lambdaQuery()
+                        .eq(SysRoleMenu::getRoleId, roleId))
+                .stream().map(SysRoleMenu::getMenuId).map(String::valueOf).collect(Collectors.toList());
         Map<String, Object> result = new LinkedHashMap<>();
         result.put("roleMenuIds", roleMenuIds);
-        result.put("tenantMenuIds", tenantMenuIds);
-        result.put("effectiveMenuIds", effective);
+        result.put("effectiveMenuIds", roleMenuIds);
         return result;
     }
 
     @Transactional
-    public void saveMenuPermissions(Long roleId, Long tenantId, List<Long> roleMenuIds, List<Long> tenantMenuIds) {
-        if (roleId == null || tenantId == null) {
-            throw new BusinessException("璇烽�夋嫨瑙掕壊鍜岀鎴�");
+    public void saveMenuPermissions(Long roleId, List<Long> roleMenuIds) {
+        if (roleId == null) {
+            throw new BusinessException("璇烽�夋嫨瑙掕壊");
         }
         roleMenuMapper.delete(Wrappers.<SysRoleMenu>lambdaQuery().eq(SysRoleMenu::getRoleId, roleId));
-        tenantMenuMapper.delete(Wrappers.<SysTenantMenu>lambdaQuery().eq(SysTenantMenu::getTenantId, tenantId));
-        for (Long menuId : roleMenuIds == null ? Collections.<Long>emptyList() : roleMenuIds) {
+        List<Long> effectiveRoleMenuIds = expandWithAncestors(roleMenuIds);
+        for (Long menuId : effectiveRoleMenuIds) {
             SysRoleMenu relation = new SysRoleMenu();
             relation.setRoleId(roleId);
             relation.setMenuId(menuId);
             relation.setCreateTime(LocalDateTime.now());
             roleMenuMapper.insert(relation);
         }
-        for (Long menuId : tenantMenuIds == null ? Collections.<Long>emptyList() : tenantMenuIds) {
-            SysTenantMenu relation = new SysTenantMenu();
-            relation.setTenantId(tenantId);
-            relation.setMenuId(menuId);
+    }
+
+    public Map<String, Object> userPage(String keyword, Long deptId, Long roleId, int current, int size) {
+        List<Object> args = new ArrayList<>();
+        StringBuilder sql = new StringBuilder();
+        sql.append("SELECT u.USER_ID, u.USER_NAME, u.NICK_NAME, u.DEPT_ID, d.DEPT_NAME, u.PHONENUMBER, u.STATUS, u.LOGIN_DATE, ");
+        sql.append("ue.ENGINEERING_MAJOR, ue.TEAM_MAJOR, ue.RESPONSIBLE_X_NO, CASE WHEN ue.USER_ID IS NULL THEN 0 ELSE 1 END AS EXT_CONFIGURED ");
+        sql.append("FROM CSICZB_ZB.SYS_USER u LEFT JOIN CSICZB_ZB.SYS_DEPT d ON d.DEPT_ID = u.DEPT_ID ");
+        sql.append("LEFT JOIN LX.T_SYS_USER_EXT ue ON ue.USER_ID = u.USER_ID AND (ue.DEL_FLAG = 0 OR ue.DEL_FLAG IS NULL) ");
+        sql.append("WHERE (u.DEL_FLAG = '0' OR u.DEL_FLAG IS NULL) ");
+        if (StringUtils.hasText(keyword)) {
+            sql.append("AND (u.USER_NAME LIKE ? OR u.NICK_NAME LIKE ?) ");
+            String like = "%" + keyword.trim() + "%";
+            args.add(like);
+            args.add(like);
+        }
+        if (deptId != null) {
+            sql.append("AND u.DEPT_ID = ? ");
+            args.add(deptId);
+        }
+        if (roleId != null) {
+            sql.append("AND EXISTS (SELECT 1 FROM LX.T_SYS_ROLE_USER ru WHERE ru.USER_ID = u.USER_ID AND ru.ROLE_ID = ? AND ru.DEL_FLAG = 0) ");
+            args.add(roleId);
+        }
+        sql.append("ORDER BY u.USER_ID");
+        List<Map<String, Object>> all = jdbcTemplate.query(sql.toString(), args.toArray(), (rs, rowNum) -> {
+            Map<String, Object> row = new LinkedHashMap<>();
+            row.put("userId", String.valueOf(rs.getLong("USER_ID")));
+            row.put("userName", rs.getString("USER_NAME"));
+            row.put("nickName", rs.getString("NICK_NAME"));
+            Object dept = rs.getObject("DEPT_ID");
+            row.put("deptId", dept == null ? null : String.valueOf(rs.getLong("DEPT_ID")));
+            row.put("deptName", rs.getString("DEPT_NAME"));
+            row.put("phoneNumber", rs.getString("PHONENUMBER"));
+            row.put("status", rs.getString("STATUS"));
+            java.sql.Timestamp loginDate = rs.getTimestamp("LOGIN_DATE");
+            row.put("loginDate", loginDate == null ? null : loginDate.toLocalDateTime().toString().replace('T', ' '));
+            row.put("engineeringMajor", rs.getString("ENGINEERING_MAJOR"));
+            row.put("teamMajor", rs.getString("TEAM_MAJOR"));
+            row.put("responsibleXNo", rs.getString("RESPONSIBLE_X_NO"));
+            row.put("extConfigured", rs.getInt("EXT_CONFIGURED") == 1);
+            row.put("roles", new ArrayList<Map<String, Object>>());
+            return row;
+        });
+        Map<Long, Map<String, Object>> byId = new HashMap<>();
+        for (Map<String, Object> row : all) {
+            byId.put(Long.valueOf(String.valueOf(row.get("userId"))), row);
+        }
+        if (!byId.isEmpty()) {
+            List<Map<String, Object>> roleRows = jdbcTemplate.query(
+                    "SELECT ru.USER_ID, r.ID, r.NAME, r.CODE, r.ROLE_TYPE FROM LX.T_SYS_ROLE_USER ru JOIN LX.T_SYS_ROLE r ON r.ID = ru.ROLE_ID AND r.DEL_FLAG = 0 WHERE ru.DEL_FLAG = 0 ORDER BY r.SORT_NO",
+                    (rs, rowNum) -> {
+                        Map<String, Object> role = new LinkedHashMap<>();
+                        role.put("userId", String.valueOf(rs.getLong("USER_ID")));
+                        role.put("roleId", String.valueOf(rs.getLong("ID")));
+                        role.put("roleName", rs.getString("NAME"));
+                        role.put("roleCode", rs.getString("CODE"));
+                        role.put("roleType", rs.getString("ROLE_TYPE"));
+                        return role;
+                    });
+            for (Map<String, Object> role : roleRows) {
+                Map<String, Object> user = byId.get(Long.valueOf(String.valueOf(role.get("userId"))));
+                if (user != null) {
+                    @SuppressWarnings("unchecked")
+                    List<Map<String, Object>> roles = (List<Map<String, Object>>) user.get("roles");
+                    roles.add(role);
+                }
+            }
+        }
+        int safeSize = size <= 0 ? 20 : size;
+        int safeCurrent = current <= 0 ? 1 : current;
+        int total = all.size();
+        int from = Math.min(total, (safeCurrent - 1) * safeSize);
+        int to = Math.min(total, from + safeSize);
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("records", new ArrayList<>(all.subList(from, to)));
+        result.put("total", total);
+        result.put("current", safeCurrent);
+        result.put("size", safeSize);
+        result.put("pages", (total + safeSize - 1) / safeSize);
+        return result;
+    }
+
+    public List<Map<String, Object>> depts() {
+        return jdbcTemplate.query(
+                "SELECT d.DEPT_ID, d.DEPT_NAME, d.PARENT_ID, d.ORDER_NUM, d.DEPT_TYPE, d.LEADER, d.PHONE, d.STATUS, d.DEPT_CODE, "
+                        + "e.RESPONSIBLE_X_NO, CASE WHEN e.DEPT_ID IS NULL THEN 0 ELSE 1 END AS EXT_CONFIGURED "
+                        + "FROM CSICZB_ZB.SYS_DEPT d "
+                        + "LEFT JOIN LX.T_SYS_DEPT_EXT e ON e.DEPT_ID = d.DEPT_ID AND (e.DEL_FLAG = 0 OR e.DEL_FLAG IS NULL) "
+                        + "WHERE (d.DEL_FLAG = '0' OR d.DEL_FLAG IS NULL) ORDER BY d.ORDER_NUM, d.DEPT_ID",
+                (rs, rowNum) -> {
+                    Map<String, Object> row = new LinkedHashMap<>();
+                    row.put("id", String.valueOf(rs.getLong("DEPT_ID")));
+                    row.put("name", rs.getString("DEPT_NAME"));
+                    Object parentId = rs.getObject("PARENT_ID");
+                    row.put("parentId", parentId == null ? null : String.valueOf(rs.getLong("PARENT_ID")));
+                    row.put("orderNum", rs.getObject("ORDER_NUM"));
+                    row.put("deptType", rs.getString("DEPT_TYPE"));
+                    row.put("leader", rs.getString("LEADER"));
+                    row.put("phone", rs.getString("PHONE"));
+                    row.put("status", rs.getString("STATUS"));
+                    row.put("deptCode", rs.getString("DEPT_CODE"));
+                    row.put("responsibleXNo", rs.getString("RESPONSIBLE_X_NO"));
+                    row.put("extConfigured", rs.getInt("EXT_CONFIGURED") == 1);
+                    return row;
+                });
+    }
+
+    public Map<String, Object> deptExt(Long deptId) {
+        List<Map<String, Object>> rows = jdbcTemplate.query(
+                "SELECT d.DEPT_NAME, e.RESPONSIBLE_X_NO FROM CSICZB_ZB.SYS_DEPT d "
+                        + "LEFT JOIN LX.T_SYS_DEPT_EXT e ON e.DEPT_ID = d.DEPT_ID AND (e.DEL_FLAG = 0 OR e.DEL_FLAG IS NULL) "
+                        + "WHERE d.DEPT_ID = ? AND (d.DEL_FLAG = '0' OR d.DEL_FLAG IS NULL)",
+                new Object[]{deptId}, (rs, rowNum) -> {
+                    Map<String, Object> row = new LinkedHashMap<>();
+                    row.put("deptId", String.valueOf(deptId));
+                    row.put("deptName", rs.getString("DEPT_NAME"));
+                    row.put("responsibleXNo", rs.getString("RESPONSIBLE_X_NO"));
+                    row.put("configured", StringUtils.hasText(rs.getString("RESPONSIBLE_X_NO")));
+                    return row;
+                });
+        if (rows.isEmpty()) {
+            throw new BusinessException("閮ㄩ棬涓嶅瓨鍦�");
+        }
+        return rows.get(0);
+    }
+
+    @Transactional
+    public void saveDeptExt(Long deptId, String responsibleXNo) {
+        assertExternalDept(deptId);
+        String xNo = normalizeExtValue(responsibleXNo);
+        assertKnownXNo(xNo);
+        String username = CurrentUserContext.get();
+        LocalDateTime now = LocalDateTime.now();
+        int delFlag = xNo == null ? 1 : 0;
+        int updated = jdbcTemplate.update(
+                "UPDATE LX.T_SYS_DEPT_EXT SET RESPONSIBLE_X_NO = ?, DEL_FLAG = ?, UPDATE_BY = ?, UPDATE_TIME = ? WHERE DEPT_ID = ?",
+                xNo, delFlag, username, now, deptId);
+        if (updated == 0) {
+            jdbcTemplate.update(
+                    "INSERT INTO LX.T_SYS_DEPT_EXT (DEPT_ID, RESPONSIBLE_X_NO, CREATE_BY, UPDATE_BY, DEL_FLAG, CREATE_TIME, UPDATE_TIME) VALUES (?, ?, ?, ?, ?, ?, ?)",
+                    deptId, xNo, username, username, delFlag, now, now);
+        }
+    }
+
+    public Map<String, Object> userExt(Long userId) {
+        List<Map<String, Object>> rows = jdbcTemplate.query(
+                "SELECT u.USER_NAME, u.NICK_NAME, e.ENGINEERING_MAJOR, e.TEAM_MAJOR, e.RESPONSIBLE_X_NO "
+                        + "FROM CSICZB_ZB.SYS_USER u "
+                        + "LEFT JOIN LX.T_SYS_USER_EXT e ON e.USER_ID = u.USER_ID AND (e.DEL_FLAG = 0 OR e.DEL_FLAG IS NULL) "
+                        + "WHERE u.USER_ID = ? AND (u.DEL_FLAG = '0' OR u.DEL_FLAG IS NULL)",
+                new Object[]{userId}, (rs, rowNum) -> {
+                    Map<String, Object> row = new LinkedHashMap<>();
+                    row.put("userId", String.valueOf(userId));
+                    row.put("userName", rs.getString("USER_NAME"));
+                    row.put("nickName", rs.getString("NICK_NAME"));
+                    row.put("engineeringMajor", rs.getString("ENGINEERING_MAJOR"));
+                    row.put("teamMajor", rs.getString("TEAM_MAJOR"));
+                    row.put("responsibleXNo", rs.getString("RESPONSIBLE_X_NO"));
+                    boolean configured = StringUtils.hasText(rs.getString("ENGINEERING_MAJOR"))
+                            || StringUtils.hasText(rs.getString("TEAM_MAJOR"))
+                            || StringUtils.hasText(rs.getString("RESPONSIBLE_X_NO"));
+                    row.put("configured", configured);
+                    return row;
+                });
+        if (rows.isEmpty()) {
+            throw new BusinessException("鐢ㄦ埛涓嶅瓨鍦�");
+        }
+        return rows.get(0);
+    }
+
+    @Transactional
+    public void saveUserExt(Long userId, String engineeringMajor, String teamMajor, String responsibleXNo) {
+        assertExternalUser(userId);
+        String engineering = normalizeExtValue(engineeringMajor);
+        String team = normalizeExtValue(teamMajor);
+        String xNo = normalizeExtValue(responsibleXNo);
+        assertKnownEngineeringMajor(engineering);
+        assertKnownTeamMajor(team);
+        assertKnownXNo(xNo);
+        boolean configured = engineering != null || team != null || xNo != null;
+        String username = CurrentUserContext.get();
+        LocalDateTime now = LocalDateTime.now();
+        int updated = jdbcTemplate.update(
+                "UPDATE LX.T_SYS_USER_EXT SET ENGINEERING_MAJOR = ?, TEAM_MAJOR = ?, RESPONSIBLE_X_NO = ?, DEL_FLAG = ?, UPDATE_BY = ?, UPDATE_TIME = ? WHERE USER_ID = ?",
+                engineering, team, xNo, configured ? 0 : 1, username, now, userId);
+        if (updated == 0) {
+            jdbcTemplate.update(
+                    "INSERT INTO LX.T_SYS_USER_EXT (USER_ID, ENGINEERING_MAJOR, TEAM_MAJOR, RESPONSIBLE_X_NO, CREATE_BY, UPDATE_BY, DEL_FLAG, CREATE_TIME, UPDATE_TIME) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
+                    userId, engineering, team, xNo, username, username, configured ? 0 : 1, now, now);
+        }
+    }
+
+    private void assertExternalDept(Long deptId) {
+        if (deptId == null) {
+            throw new BusinessException("璇烽�夋嫨閮ㄩ棬");
+        }
+        Integer count = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM CSICZB_ZB.SYS_DEPT WHERE DEPT_ID = ? AND (DEL_FLAG = '0' OR DEL_FLAG IS NULL)",
+                Integer.class, deptId);
+        if (count == null || count == 0) {
+            throw new BusinessException("閮ㄩ棬涓嶅瓨鍦�");
+        }
+    }
+
+    private void assertExternalUser(Long userId) {
+        if (userId == null) {
+            throw new BusinessException("璇烽�夋嫨鐢ㄦ埛");
+        }
+        Integer count = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM CSICZB_ZB.SYS_USER WHERE USER_ID = ? AND (DEL_FLAG = '0' OR DEL_FLAG IS NULL)",
+                Integer.class, userId);
+        if (count == null || count == 0) {
+            throw new BusinessException("鐢ㄦ埛涓嶅瓨鍦�");
+        }
+    }
+
+    private void assertKnownXNo(String xNo) {
+        if (xNo != null && !productXNumbers().contains(xNo)) {
+            throw new BusinessException("rk_X鍙峰繀椤讳粠浜у搧缁撴瀯鏍戠殑side鑺傜偣涓�夋嫨");
+        }
+    }
+
+    private void assertKnownEngineeringMajor(String value) {
+        if (value != null && listEngineeringMajors().stream().map(EngineeringMajor::getName)
+                .filter(StringUtils::hasText).noneMatch(item -> value.equals(item.trim()))) {
+            throw new BusinessException("宸ョ▼涓撲笟涓嶅湪鍙�夎寖鍥村唴");
+        }
+    }
+
+    private void assertKnownTeamMajor(String value) {
+        if (value != null && listTeamMajors().stream().map(TeamMajor::getName)
+                .filter(StringUtils::hasText).noneMatch(item -> value.equals(item.trim()))) {
+            throw new BusinessException("rk_T闃熶笓涓氫笉鍦ㄥ彲閫夎寖鍥村唴");
+        }
+    }
+
+    private String normalizeExtValue(String value) {
+        return StringUtils.hasText(value) ? value.trim() : null;
+    }
+
+    public List<String> userRoleIds(Long userId) {
+        if (userId == null) {
+            return new ArrayList<>();
+        }
+        return roleUserMapper.selectList(Wrappers.<SysRoleUser>lambdaQuery()
+                        .eq(SysRoleUser::getUserId, userId))
+                .stream().map(item -> String.valueOf(item.getRoleId())).collect(Collectors.toList());
+    }
+
+    public Map<String, Object> roleUsers(Long roleId) {
+        if (roleId == null || roleMapper.selectById(roleId) == null) {
+            throw new BusinessException("瑙掕壊涓嶅瓨鍦�");
+        }
+        Set<Long> assignedUserIds = roleUserMapper.selectList(Wrappers.<SysRoleUser>lambdaQuery()
+                        .eq(SysRoleUser::getRoleId, roleId))
+                .stream().map(SysRoleUser::getUserId).collect(Collectors.toCollection(LinkedHashSet::new));
+        List<Map<String, Object>> users = jdbcTemplate.query(
+                "SELECT u.USER_ID, u.USER_NAME, u.NICK_NAME, u.DEPT_ID, d.DEPT_NAME, u.STATUS " +
+                        "FROM CSICZB_ZB.SYS_USER u LEFT JOIN CSICZB_ZB.SYS_DEPT d ON d.DEPT_ID = u.DEPT_ID " +
+                        "WHERE (u.DEL_FLAG = '0' OR u.DEL_FLAG IS NULL) ORDER BY u.USER_ID",
+                (rs, rowNum) -> {
+                    Long userId = rs.getLong("USER_ID");
+                    Map<String, Object> user = new LinkedHashMap<>();
+                    user.put("userId", String.valueOf(userId));
+                    user.put("userName", rs.getString("USER_NAME"));
+                    user.put("nickName", rs.getString("NICK_NAME"));
+                    Object deptId = rs.getObject("DEPT_ID");
+                    user.put("deptId", deptId == null ? null : String.valueOf(rs.getLong("DEPT_ID")));
+                    user.put("deptName", rs.getString("DEPT_NAME"));
+                    user.put("status", rs.getString("STATUS"));
+                    user.put("assigned", assignedUserIds.contains(userId));
+                    return user;
+                });
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("records", users);
+        result.put("userIds", assignedUserIds.stream().map(String::valueOf).collect(Collectors.toList()));
+        result.put("total", users.size());
+        return result;
+    }
+
+    @Transactional
+    public void saveRoleUsers(Long roleId, List<Long> userIds) {
+        if (roleId == null || roleMapper.selectById(roleId) == null) {
+            throw new BusinessException("瑙掕壊涓嶅瓨鍦�");
+        }
+        Set<Long> distinct = new LinkedHashSet<>();
+        if (userIds != null) {
+            distinct.addAll(userIds);
+        }
+        distinct.remove(null);
+        for (Long userId : distinct) {
+            Integer exists = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM CSICZB_ZB.SYS_USER WHERE USER_ID = ? AND (DEL_FLAG = '0' OR DEL_FLAG IS NULL)",
+                    Integer.class, userId);
+            if (exists == null || exists == 0) {
+                throw new BusinessException("鐢ㄦ埛涓嶅瓨鍦�");
+            }
+        }
+        roleUserMapper.delete(Wrappers.<SysRoleUser>lambdaQuery().eq(SysRoleUser::getRoleId, roleId));
+        for (Long userId : distinct) {
+            SysRoleUser relation = new SysRoleUser();
+            relation.setRoleId(roleId);
+            relation.setUserId(userId);
             relation.setCreateTime(LocalDateTime.now());
-            tenantMenuMapper.insert(relation);
+            roleUserMapper.insert(relation);
+        }
+    }
+
+    @Transactional
+    public void saveUserRoles(Long userId, List<Long> roleIds) {
+        if (userId == null) {
+            throw new BusinessException("璇烽�夋嫨鐢ㄦ埛");
+        }
+        Integer exists = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM CSICZB_ZB.SYS_USER WHERE USER_ID = ? AND (DEL_FLAG = '0' OR DEL_FLAG IS NULL)",
+                Integer.class, userId);
+        if (exists == null || exists == 0) {
+            throw new BusinessException("鐢ㄦ埛涓嶅瓨鍦�");
+        }
+        Set<Long> distinct = new LinkedHashSet<>();
+        if (roleIds != null) {
+            distinct.addAll(roleIds);
+        }
+        for (Long roleId : distinct) {
+            if (roleId != null && roleMapper.selectById(roleId) == null) {
+                throw new BusinessException("瑙掕壊涓嶅瓨鍦�");
+            }
+        }
+        roleUserMapper.delete(Wrappers.<SysRoleUser>lambdaQuery().eq(SysRoleUser::getUserId, userId));
+        for (Long roleId : distinct) {
+            if (roleId == null) {
+                continue;
+            }
+            SysRoleUser relation = new SysRoleUser();
+            relation.setRoleId(roleId);
+            relation.setUserId(userId);
+            relation.setCreateTime(LocalDateTime.now());
+            roleUserMapper.insert(relation);
+        }
+    }
+
+    @Transactional
+    public void saveUserRolesBatch(List<Long> userIds, List<Long> roleIds, String mode) {
+        Set<Long> distinctUsers = new LinkedHashSet<>();
+        if (userIds != null) {
+            distinctUsers.addAll(userIds);
+        }
+        distinctUsers.remove(null);
+        if (distinctUsers.isEmpty()) {
+            throw new BusinessException("\u8bf7\u9009\u62e9\u7528\u6237");
+        }
+        Set<Long> distinctRoles = new LinkedHashSet<>();
+        if (roleIds != null) {
+            distinctRoles.addAll(roleIds);
+        }
+        distinctRoles.remove(null);
+        for (Long roleId : distinctRoles) {
+            if (roleMapper.selectById(roleId) == null) {
+                throw new BusinessException("\u89d2\u8272\u4e0d\u5b58\u5728");
+            }
+        }
+        boolean append = !"replace".equalsIgnoreCase(mode == null ? "" : mode.trim());
+        for (Long userId : distinctUsers) {
+            Integer exists = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM CSICZB_ZB.SYS_USER WHERE USER_ID = ? AND (DEL_FLAG = '0' OR DEL_FLAG IS NULL)",
+                    Integer.class, userId);
+            if (exists == null || exists == 0) {
+                throw new BusinessException("\u7528\u6237\u4e0d\u5b58\u5728");
+            }
+            Set<Long> targetRoleIds = new LinkedHashSet<>();
+            if (append) {
+                targetRoleIds.addAll(roleUserMapper.selectList(Wrappers.<SysRoleUser>lambdaQuery()
+                                .eq(SysRoleUser::getUserId, userId))
+                        .stream().map(SysRoleUser::getRoleId).collect(Collectors.toList()));
+            }
+            targetRoleIds.addAll(distinctRoles);
+            roleUserMapper.delete(Wrappers.<SysRoleUser>lambdaQuery().eq(SysRoleUser::getUserId, userId));
+            for (Long roleId : targetRoleIds) {
+                SysRoleUser relation = new SysRoleUser();
+                relation.setRoleId(roleId);
+                relation.setUserId(userId);
+                relation.setCreateTime(LocalDateTime.now());
+                roleUserMapper.insert(relation);
+            }
+        }
+    }
+
+    public List<String> roleCodes(String username) {
+        Long userId = resolveUserId(username);
+        if (userId == null) {
+            return new ArrayList<>();
+        }
+        List<Long> roleIds = roleUserMapper.selectList(Wrappers.<SysRoleUser>lambdaQuery()
+                        .eq(SysRoleUser::getUserId, userId))
+                .stream().map(SysRoleUser::getRoleId).collect(Collectors.toList());
+        if (roleIds.isEmpty()) {
+            return new ArrayList<>();
+        }
+        return roleMapper.selectBatchIds(roleIds).stream()
+                .map(SysRole::getCode).filter(StringUtils::hasText).sorted().collect(Collectors.toList());
+    }
+
+    public Set<Long> effectiveMenuIdsForUser(String username) {
+        Set<Long> result = new LinkedHashSet<>();
+        if (!StringUtils.hasText(username)) {
+            return result;
+        }
+        if ("admin".equals(username)) {
+            listMenus().forEach(menu -> result.add(menu.getId()));
+            return result;
+        }
+        Long userId = resolveUserId(username);
+        if (userId == null) {
+            return result;
+        }
+        List<Long> roleIds = roleUserMapper.selectList(Wrappers.<SysRoleUser>lambdaQuery()
+                        .eq(SysRoleUser::getUserId, userId))
+                .stream().map(SysRoleUser::getRoleId).collect(Collectors.toList());
+        if (roleIds.isEmpty()) {
+            return result;
+        }
+        Set<Long> roleMenuIds = new LinkedHashSet<>(roleMenuMapper.selectList(Wrappers.<SysRoleMenu>lambdaQuery()
+                        .in(SysRoleMenu::getRoleId, roleIds))
+                .stream().map(SysRoleMenu::getMenuId).collect(Collectors.toList()));
+        result.addAll(roleMenuIds);
+        return result;
+    }
+
+    public List<SysMenu> menuTreeForUser(String username) {
+        Set<Long> allowed = new LinkedHashSet<>(effectiveMenuIdsForUser(username));
+        if (allowed.isEmpty() && !"admin".equals(username)) {
+            SysMenu dashboard = menuMapper.selectOne(Wrappers.<SysMenu>lambdaQuery()
+                    .eq(SysMenu::getUrl, "/dashboard").last("LIMIT 1"));
+            if (dashboard != null) {
+                allowed.add(dashboard.getId());
+            }
+        }
+        final Set<Long> allowedIds = allowed;
+        Set<Long> visibleIds = new LinkedHashSet<>(expandWithAncestors(new ArrayList<>(allowed)));
+        List<SysMenu> navigable = listMenus().stream()
+                .filter(menu -> menu.getMenuType() == null || menu.getMenuType() != 2)
+                .filter(menu -> visibleIds.contains(menu.getId()))
+                .collect(Collectors.toList());
+        return buildTree(navigable);
+    }
+
+    public List<String> permissionCodes(String username) {
+        Set<Long> menuIds = effectiveMenuIdsForUser(username);
+        return listMenus().stream()
+                .filter(menu -> menuIds.contains(menu.getId()))
+                .map(SysMenu::getPermissions)
+                .filter(StringUtils::hasText)
+                .distinct()
+                .sorted()
+                .collect(Collectors.toList());
+    }
+
+    public Map<String, Object> menuButtons(Long menuId) {
+        SysMenu menu = menuMapper.selectById(menuId);
+        if (menu == null) {
+            throw new BusinessException("鑿滃崟涓嶅瓨鍦�");
+        }
+        List<Map<String, Object>> available = new ArrayList<>();
+        for (ButtonPermissionCatalog.ButtonDefinition definition : buttonPermissionCatalog.buttonsFor(menu.getUrl())) {
+            Map<String, Object> row = new LinkedHashMap<>();
+            row.put("code", definition.getCode());
+            row.put("name", definition.getName());
+            available.add(row);
+        }
+        List<String> selected = menuMapper.selectList(Wrappers.<SysMenu>lambdaQuery()
+                        .eq(SysMenu::getPid, menuId)
+                        .eq(SysMenu::getMenuType, 2))
+                .stream().map(SysMenu::getPermissions).filter(StringUtils::hasText).collect(Collectors.toList());
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("menuId", String.valueOf(menuId));
+        result.put("menuName", menu.getName());
+        result.put("available", available);
+        result.put("selected", selected);
+        return result;
+    }
+
+    @Transactional
+    public void syncMenuButtons(Long menuId, List<String> codes) {
+        SysMenu menu = menuMapper.selectById(menuId);
+        if (menu == null) {
+            throw new BusinessException("鑿滃崟涓嶅瓨鍦�");
+        }
+        if (menu.getMenuType() == null || menu.getMenuType() != 1) {
+            throw new BusinessException("鍙湁鑿滃崟绫诲瀷鎵嶈兘閰嶇疆鎸夐挳");
+        }
+        List<ButtonPermissionCatalog.ButtonDefinition> definitions = buttonPermissionCatalog.buttonsFor(menu.getUrl());
+        Map<String, ButtonPermissionCatalog.ButtonDefinition> definitionMap = new LinkedHashMap<>();
+        for (ButtonPermissionCatalog.ButtonDefinition definition : definitions) {
+            definitionMap.put(definition.getCode(), definition);
+        }
+        Set<String> wanted = new LinkedHashSet<>();
+        if (codes != null) {
+            for (String code : codes) {
+                if (StringUtils.hasText(code) && definitionMap.containsKey(code)) {
+                    wanted.add(code);
+                }
+            }
+        }
+        List<SysMenu> existing = menuMapper.selectList(Wrappers.<SysMenu>lambdaQuery()
+                .eq(SysMenu::getPid, menuId).eq(SysMenu::getMenuType, 2));
+        Map<String, SysMenu> existingByCode = new HashMap<>();
+        for (SysMenu child : existing) {
+            existingByCode.put(child.getPermissions(), child);
+            if (!wanted.contains(child.getPermissions())) {
+                menuMapper.deleteById(child.getId());
+                roleMenuMapper.delete(Wrappers.<SysRoleMenu>lambdaQuery().eq(SysRoleMenu::getMenuId, child.getId()));
+                tenantMenuMapper.delete(Wrappers.<SysTenantMenu>lambdaQuery().eq(SysTenantMenu::getMenuId, child.getId()));
+            }
+        }
+        LocalDateTime now = LocalDateTime.now();
+        for (int index = 0; index < definitions.size(); index++) {
+            ButtonPermissionCatalog.ButtonDefinition definition = definitions.get(index);
+            if (!wanted.contains(definition.getCode())) {
+                continue;
+            }
+            SysMenu child = existingByCode.get(definition.getCode());
+            if (child == null) {
+                child = new SysMenu();
+                child.setPid(menuId);
+                child.setName(definition.getName());
+                child.setPermissions(definition.getCode());
+                child.setMenuType(2);
+                child.setSortNo((index + 1) * 10);
+                child.setVisible(1);
+                child.setCreateTime(now);
+                child.setUpdateTime(now);
+                menuMapper.insert(child);
+            } else {
+                child.setName(definition.getName());
+                child.setSortNo((index + 1) * 10);
+                child.setUpdateTime(now);
+                menuMapper.updateById(child);
+            }
+        }
+    }
+
+    public Page<SysLoginLog> loginLogPage(int current, int size, String keyword, String status, String startDate, String endDate) {
+        Page<SysLoginLog> page = new Page<>(current <= 0 ? 1 : current, size <= 0 ? 20 : size);
+        LambdaQueryWrapper<SysLoginLog> wrapper = Wrappers.lambdaQuery();
+        if (StringUtils.hasText(keyword)) {
+            String value = keyword.trim();
+            wrapper.and(item -> item.like(SysLoginLog::getUserName, value).or().like(SysLoginLog::getNickName, value));
+        }
+        if (StringUtils.hasText(status)) {
+            wrapper.eq(SysLoginLog::getStatus, status.trim());
+        }
+        LocalDateTime start = parseStart(startDate);
+        LocalDateTime end = parseEnd(endDate);
+        if (start != null) {
+            wrapper.ge(SysLoginLog::getLoginTime, start);
+        }
+        if (end != null) {
+            wrapper.le(SysLoginLog::getLoginTime, end);
+        }
+        wrapper.orderByDesc(SysLoginLog::getLoginTime);
+        return loginLogMapper.selectPage(page, wrapper);
+    }
+
+    public Page<SysOperationLog> operationLogPage(int current, int size, String keyword, String module, String operationType, String startDate, String endDate) {
+        Page<SysOperationLog> page = new Page<>(current <= 0 ? 1 : current, size <= 0 ? 20 : size);
+        LambdaQueryWrapper<SysOperationLog> wrapper = Wrappers.lambdaQuery();
+        if (StringUtils.hasText(keyword)) {
+            String value = keyword.trim();
+            wrapper.and(item -> item.like(SysOperationLog::getUserName, value)
+                    .or().like(SysOperationLog::getNickName, value)
+                    .or().like(SysOperationLog::getRequestUri, value));
+        }
+        if (StringUtils.hasText(module)) {
+            wrapper.eq(SysOperationLog::getModule, module.trim());
+        }
+        if (StringUtils.hasText(operationType)) {
+            wrapper.eq(SysOperationLog::getOperationType, operationType.trim());
+        }
+        LocalDateTime start = parseStart(startDate);
+        LocalDateTime end = parseEnd(endDate);
+        if (start != null) {
+            wrapper.ge(SysOperationLog::getCreateTime, start);
+        }
+        if (end != null) {
+            wrapper.le(SysOperationLog::getCreateTime, end);
+        }
+        wrapper.orderByDesc(SysOperationLog::getCreateTime);
+        return operationLogMapper.selectPage(page, wrapper);
+    }
+
+    public Page<SysErrorLog> errorLogPage(int current, int size, String keyword, String module, String exceptionType, String startDate, String endDate) {
+        Page<SysErrorLog> page = new Page<>(current <= 0 ? 1 : current, size <= 0 ? 20 : size);
+        LambdaQueryWrapper<SysErrorLog> wrapper = Wrappers.lambdaQuery();
+        wrapper.select(SysErrorLog::getId, SysErrorLog::getUserId, SysErrorLog::getUserName,
+                SysErrorLog::getNickName, SysErrorLog::getModule, SysErrorLog::getRequestMethod,
+                SysErrorLog::getRequestUri, SysErrorLog::getExceptionType, SysErrorLog::getMessage,
+                SysErrorLog::getErrorTime);
+        if (StringUtils.hasText(keyword)) {
+            String value = keyword.trim();
+            wrapper.and(item -> item.like(SysErrorLog::getUserName, value)
+                    .or().like(SysErrorLog::getNickName, value)
+                    .or().like(SysErrorLog::getRequestUri, value)
+                    .or().like(SysErrorLog::getExceptionType, value)
+                    .or().like(SysErrorLog::getMessage, value));
+        }
+        if (StringUtils.hasText(module)) {
+            wrapper.eq(SysErrorLog::getModule, module.trim());
+        }
+        if (StringUtils.hasText(exceptionType)) {
+            wrapper.like(SysErrorLog::getExceptionType, exceptionType.trim());
+        }
+        LocalDateTime start = parseStart(startDate);
+        LocalDateTime end = parseEnd(endDate);
+        if (start != null) {
+            wrapper.ge(SysErrorLog::getErrorTime, start);
+        }
+        if (end != null) {
+            wrapper.le(SysErrorLog::getErrorTime, end);
+        }
+        wrapper.orderByDesc(SysErrorLog::getErrorTime);
+        return errorLogMapper.selectPage(page, wrapper);
+    }
+
+    public SysErrorLog errorLog(Long id) {
+        SysErrorLog log = errorLogMapper.selectById(id);
+        if (log == null) {
+            throw new BusinessException("閿欒鏃ュ織涓嶅瓨鍦�");
+        }
+        return log;
+    }
+
+    public Long resolveUserId(String username) {
+        if (!StringUtils.hasText(username)) {
+            return null;
+        }
+        List<Long> ids = jdbcTemplate.query(
+                "SELECT USER_ID FROM CSICZB_ZB.SYS_USER WHERE USER_NAME = ? AND (DEL_FLAG = '0' OR DEL_FLAG IS NULL)",
+                new Object[]{username}, (rs, rowNum) -> rs.getLong(1));
+        return ids.isEmpty() ? null : ids.get(0);
+    }
+
+    private List<Long> expandWithAncestors(List<Long> ids) {
+        Set<Long> result = new LinkedHashSet<>();
+        if (ids == null) {
+            return new ArrayList<>(result);
+        }
+        for (Long id : ids) {
+            Long cursor = id;
+            int guard = 0;
+            while (cursor != null && cursor != 0L && guard++ < 32) {
+                if (!result.add(cursor)) {
+                    break;
+                }
+                SysMenu menu = menuMapper.selectById(cursor);
+                if (menu == null) {
+                    break;
+                }
+                cursor = menu.getPid();
+            }
+        }
+        return new ArrayList<>(result);
+    }
+
+    private LocalDateTime parseStart(String value) {
+        if (!StringUtils.hasText(value)) {
+            return null;
+        }
+        try {
+            return LocalDate.parse(value.trim()).atStartOfDay();
+        } catch (Exception ex) {
+            return null;
+        }
+    }
+
+    private LocalDateTime parseEnd(String value) {
+        if (!StringUtils.hasText(value)) {
+            return null;
+        }
+        try {
+            return LocalDate.parse(value.trim()).atTime(23, 59, 59);
+        } catch (Exception ex) {
+            return null;
         }
     }
 
@@ -394,20 +1309,70 @@
     }
 
     private Map<String, Object> mapProduct(ProductNode node) {
+        return mapProduct(node, Collections.emptySet());
+    }
+
+    private Map<String, Object> mapProduct(ProductNode node, Set<Long> branchIds) {
         Map<String, Object> row = new LinkedHashMap<>();
         row.put("id", String.valueOf(node.getId()));
         row.put("parentId", node.getParentId() == null ? null : String.valueOf(node.getParentId()));
         row.put("nodeLevel", node.getNodeLevel());
         row.put("nodeType", node.getNodeType());
+        row.put("layer", node.getLayer());
+        row.put("connectGroup", node.getConnectGroup());
         row.put("name", node.getName());
         row.put("code", node.getCode());
         row.put("baseEquipmentId", node.getBaseEquipmentId() == null ? null : String.valueOf(node.getBaseEquipmentId()));
+        row.put("project", node.getProject());
         row.put("sortNo", node.getSortNo());
         row.put("remark", node.getRemark());
+        row.put("isLeaf", !branchIds.contains(node.getId()));
         row.put("children", new ArrayList<>());
         return row;
     }
 
+    private int productNodeLevel(ProductNode node) {
+        Integer level = node.getNodeLevel();
+        return level == null ? Integer.MAX_VALUE : level;
+    }
+
+    private String resolveProductLayer(ProductNode node) {
+        String layer = ProductNode.layerForType(node.getNodeType());
+        if (!StringUtils.hasText(layer)) {
+            layer = ProductNode.layerForLevel(node.getNodeLevel());
+        }
+        if (!StringUtils.hasText(layer)) {
+            layer = StringUtils.hasText(node.getLayer()) ? node.getLayer().trim().toLowerCase() : null;
+        }
+        if (!StringUtils.hasText(layer)) {
+            throw new BusinessException("鑺傜偣灞傜骇涓嶈兘涓虹┖");
+        }
+        return layer;
+    }
+
+    private String buildConnectGroup(Long parentId) {
+        if (parentId == null || parentId == 0L) {
+            return "0";
+        }
+        ProductNode parent = findProductNode(parentId);
+        if (parent == null) {
+            throw new BusinessException("鐖惰妭鐐逛笉瀛樺湪");
+        }
+        String prefix = StringUtils.hasText(parent.getConnectGroup()) ? parent.getConnectGroup() : "0";
+        return prefix + "," + parent.getId();
+    }
+
+    private ProductNode findProductNode(Long id) {
+        if (id == null) {
+            return null;
+        }
+        List<ProductNode> nodes = productNodeMapper.selectList(Wrappers.<ProductNode>lambdaQuery()
+                .eq(ProductNode::getId, id)
+                .orderByAsc(ProductNode::getRecordId)
+                .last("LIMIT 1"));
+        return nodes.isEmpty() ? null : nodes.get(0);
+    }
+
     private Map<String, Object> mapBase(BaseEquipment node) {
         Map<String, Object> row = new LinkedHashMap<>();
         row.put("id", String.valueOf(node.getId()));

--
Gitblit v1.9.1