package com.designhub.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.designhub.common.ResultCode; import com.designhub.entity.DingTalkConfig; import com.designhub.exception.BusinessException; import com.designhub.entity.Department; import com.designhub.entity.User; import com.designhub.mapper.DepartmentMapper; import com.designhub.mapper.DingTalkConfigMapper; import com.designhub.mapper.UserMapper; import org.springframework.util.StringUtils; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.RestTemplate; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.util.*; /** * 钉钉集成 Service */ @Slf4j @Service @RequiredArgsConstructor public class DingTalkService { private final DingTalkConfigMapper configMapper; private final ObjectMapper objectMapper; private final RestTemplate restTemplate = new RestTemplate(); private final DepartmentMapper departmentMapper; private final UserMapper userMapper; private final SystemConfigService systemConfigService; private static final String DINGTALK_API_BASE = "https://oapi.dingtalk.com"; private static final String DEFAULT_SITE_URL = "https://your-domain.com"; /** * 获取钉钉配置(优先获取启用的配置,如果没有则获取最新的配置) */ public DingTalkConfig getConfig() { // 1. 优先获取启用的配置 LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.eq(DingTalkConfig::getEnabled, 1) .orderByDesc(DingTalkConfig::getUpdateTime) .last("LIMIT 1"); DingTalkConfig config = configMapper.selectOne(wrapper); // 2. 如果没有启用的配置,获取最新的配置(用于管理界面显示) if (config == null) { wrapper = new LambdaQueryWrapper<>(); wrapper.orderByDesc(DingTalkConfig::getUpdateTime) .last("LIMIT 1"); config = configMapper.selectOne(wrapper); } return config; } /** * 检查钉钉是否已配置且启用 */ public boolean isConfigured() { DingTalkConfig config = getConfig(); return config != null && config.getEnabled() == 1 && config.getAppKey() != null && !config.getAppKey().isEmpty() && config.getAppSecret() != null && !config.getAppSecret().isEmpty(); } /** * 保存钉钉配置 */ @Transactional public DingTalkConfig saveConfig(DingTalkConfig config) { // 如果enabledFeatures不是字符串格式,转换为JSON字符串 if (config.getEnabledFeatures() != null && !config.getEnabledFeatures().trim().startsWith("{")) { // 已经是JSON字符串,不需要处理 } if (config.getId() != null) { configMapper.updateById(config); } else { config.setEnabled(1); configMapper.insert(config); } return config; } /** * 测试钉钉连接 */ public boolean testConnection(DingTalkConfig config) { try { String accessToken = getAccessToken(config); return accessToken != null && !accessToken.isEmpty(); } catch (Exception e) { log.error("钉钉连接测试失败", e); return false; } } /** * 获取 Access Token */ public String getAccessToken(DingTalkConfig config) { // 检查缓存的token是否有效 if (config.getAccessToken() != null && config.getTokenExpireTime() != null && config.getTokenExpireTime().isAfter(LocalDateTime.now())) { return config.getAccessToken(); } try { String url = String.format("%s/gettoken?appkey=%s&appsecret=%s", DINGTALK_API_BASE, config.getAppKey(), config.getAppSecret()); ResponseEntity response = restTemplate.getForEntity(url, Map.class); Map body = response.getBody(); if (body != null) { Object errcode = body.get("errcode"); // 安全地比较errcode(可能是Integer或Long类型) if (errcode != null && (errcode.equals(0) || errcode.equals(0L) || "0".equals(errcode.toString()))) { String accessToken = (String) body.get("access_token"); Object expiresInObj = body.get("expires_in"); int expiresIn = expiresInObj instanceof Integer ? (Integer) expiresInObj : expiresInObj instanceof Long ? ((Long) expiresInObj).intValue() : 7200; // 缓存token(只有在config有id时才更新数据库) if (config.getId() != null) { config.setAccessToken(accessToken); config.setTokenExpireTime(LocalDateTime.now().plusSeconds(expiresIn - 300)); // 提前5分钟过期 configMapper.updateById(config); } return accessToken; } else { log.error("获取钉钉AccessToken失败: errcode={}, errmsg={}, body={}", errcode, body.get("errmsg"), body); return null; } } else { log.error("获取钉钉AccessToken失败: 响应体为空"); return null; } } catch (Exception e) { log.error("获取钉钉AccessToken异常", e); return null; } } /** * 根据手机号获取userId */ public String getUserIdByMobile(String mobile) { DingTalkConfig config = getConfig(); if (config == null) return null; String accessToken = getAccessToken(config); try { String url = String.format("%s/topapi/v2/user/getbymobile?access_token=%s", DINGTALK_API_BASE, accessToken); Map request = new HashMap<>(); request.put("mobile", mobile); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity> entity = new HttpEntity<>(request, headers); ResponseEntity response = restTemplate.postForEntity(url, entity, Map.class); Map body = response.getBody(); if (body != null) { Object errcode = body.get("errcode"); if (errcode != null && (errcode.equals(0) || errcode.equals(0L) || "0".equals(errcode.toString()))) { Map result = (Map) body.get("result"); return (String) result.get("userid"); } else { log.warn("根据手机号获取userId失败: mobile={}, errcode={}, errmsg={}", mobile, errcode, body.get("errmsg")); } } return null; } catch (Exception e) { log.error("根据手机号获取userId异常", e); return null; } } /** * 获取配置的官网地址 */ private String getSiteUrl() { String siteUrl = systemConfigService.getConfigValue("site_url"); if (siteUrl == null || siteUrl.trim().isEmpty()) { siteUrl = systemConfigService.getConfigValue("dingtalk_redirect_url"); } if (siteUrl == null || siteUrl.trim().isEmpty()) { siteUrl = DEFAULT_SITE_URL; } return siteUrl; } /** * 构建钉钉跳转链接 * 如果配置了正确的corpId(以ding开头),使用应用内跳转;否则使用简单外链方式 * @param config 钉钉配置 * @param targetUrl 目标跳转地址 * @return 钉钉跳转链接 */ private String buildDingTalkAppLink(DingTalkConfig config, String targetUrl) { try { String corpId = config.getCorpId(); String agentId = config.getAgentId(); // 检查corpId是否是有效的钉钉企业ID格式(以ding开头) boolean validCorpId = corpId != null && corpId.trim().startsWith("ding"); boolean validAgentId = agentId != null && !agentId.trim().isEmpty(); if (validCorpId && validAgentId) { // 使用应用内跳转 String appId = "0_" + agentId.trim(); String encodedUrl = URLEncoder.encode(targetUrl, StandardCharsets.UTF_8.toString()); String dingTalkLink = String.format( "dingtalk://dingtalkclient/action/openapp?corpid=%s&container_type=work_platform&app_id=%s&redirect_type=jump&redirect_url=%s", corpId.trim(), appId, encodedUrl ); log.debug("使用钉钉应用内跳转: {}", dingTalkLink); return dingTalkLink; } else { // corpId格式不正确,使用简单外链方式 log.info("corpId格式不正确或未配置,使用简单外链跳转。当前corpId: {}", corpId); return targetUrl; } } catch (Exception e) { log.error("构建钉钉跳转链接失败", e); return targetUrl; } } /** * 发送工作通知(卡片消息格式) * 用户点击卡片可跳转到官网 */ public boolean sendWorkNotification(List userIds, String title, String content) { return sendWorkNotification(userIds, title, content, null); } /** * 发送工作通知(卡片消息格式,支持自定义跳转链接) * @param userIds 接收用户ID列表 * @param title 消息标题 * @param content 消息内容(支持markdown格式) * @param customUrl 自定义跳转链接,为空则使用官网地址 */ public boolean sendWorkNotification(List userIds, String title, String content, String customUrl) { DingTalkConfig config = getConfig(); if (config == null) { log.warn("钉钉未配置,跳过发送通知"); return false; } try { String accessToken = getAccessToken(config); if (accessToken == null) { return false; } String url = String.format("%s/topapi/message/corpconversation/asyncsend_v2?access_token=%s", DINGTALK_API_BASE, accessToken); // 获取目标跳转地址 String targetUrl = (customUrl != null && !customUrl.trim().isEmpty()) ? customUrl : getSiteUrl(); // 构建钉钉应用内跳转链接 String jumpUrl = buildDingTalkAppLink(config, targetUrl); // 构建卡片消息 Map msg = new HashMap<>(); msg.put("msgtype", "action_card"); Map actionCard = new HashMap<>(); actionCard.put("title", title); // 构建 markdown 正文(卡片已有标题,内容中不再重复) String markdown = content; actionCard.put("markdown", markdown); actionCard.put("single_title", "查看详情 »"); actionCard.put("single_url", jumpUrl); msg.put("action_card", actionCard); Map request = new HashMap<>(); request.put("agent_id", config.getAgentId()); request.put("userid_list", String.join(",", userIds)); request.put("msg", msg); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity> entity = new HttpEntity<>(request, headers); ResponseEntity response = restTemplate.postForEntity(url, entity, Map.class); Map body = response.getBody(); if (body != null) { Object errcode = body.get("errcode"); if (errcode != null && (errcode.equals(0) || errcode.equals(0L) || "0".equals(errcode.toString()))) { log.info("钉钉工作通知发送成功: userIds={}, title={}", userIds, title); return true; } else { log.error("钉钉工作通知发送失败: errcode={}, errmsg={}, body={}", errcode, body.get("errmsg"), body); return false; } } else { log.error("钉钉工作通知发送失败: 响应体为空"); return false; } } catch (Exception e) { log.error("发送钉钉工作通知异常", e); return false; } } /** * 发送机器人消息(卡片格式) */ public boolean sendRobotMessage(String title, String content) { return sendRobotMessage(title, content, null); } /** * 发送机器人消息(Markdown格式),支持@指定人(通过手机号) * 注意:群机器人只支持 text/link/markdown/feedCard 类型,不支持 action_card * @param title 消息标题 * @param content 消息内容(支持markdown格式) * @param atMobiles 要@的手机号列表,null或空表示不@任何人 */ public boolean sendRobotMessage(String title, String content, List atMobiles) { DingTalkConfig config = getConfig(); if (config == null || config.getRobotWebhook() == null) { log.warn("钉钉机器人未配置"); return false; } try { String webhook = config.getRobotWebhook(); // 如果有密钥,需要签名 if (config.getRobotSecret() != null && !config.getRobotSecret().isEmpty()) { long timestamp = System.currentTimeMillis(); String sign = generateSign(timestamp, config.getRobotSecret()); webhook = webhook + "×tamp=" + timestamp + "&sign=" + sign; } // 获取官网地址并构建钉钉应用内跳转链接 String siteUrl = getSiteUrl(); String jumpUrl = buildDingTalkAppLink(config, siteUrl); // 构建 Markdown 消息(群机器人支持的格式) Map message = new HashMap<>(); message.put("msgtype", "markdown"); Map markdown = new HashMap<>(); markdown.put("title", title); // 构建 markdown 正文 StringBuilder markdownBuilder = new StringBuilder(); markdownBuilder.append("### ").append(title).append("\n\n"); markdownBuilder.append(content); // 添加跳转链接 markdownBuilder.append("\n\n---\n\n"); markdownBuilder.append("[>>> 点击查看详情](").append(jumpUrl).append(")"); // 如果有@人,在内容中添加@标记 if (atMobiles != null && !atMobiles.isEmpty()) { markdownBuilder.append("\n\n"); for (String mobile : atMobiles) { markdownBuilder.append("@").append(mobile).append(" "); } } markdown.put("text", markdownBuilder.toString()); message.put("markdown", markdown); // 添加@人功能 if (atMobiles != null && !atMobiles.isEmpty()) { Map at = new HashMap<>(); at.put("atMobiles", atMobiles); at.put("isAtAll", false); message.put("at", at); log.info("群消息将@手机号: {}", atMobiles); } HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity> entity = new HttpEntity<>(message, headers); ResponseEntity response = restTemplate.postForEntity(webhook, entity, Map.class); Map body = response.getBody(); if (body != null) { Object errcode = body.get("errcode"); if (errcode != null && (errcode.equals(0) || errcode.equals(0L) || "0".equals(errcode.toString()))) { log.info("钉钉机器人Markdown消息发送成功: title={}", title); return true; } else { log.error("钉钉机器人消息发送失败: errcode={}, errmsg={}, body={}", errcode, body.get("errmsg"), body); return false; } } else { log.error("钉钉机器人消息发送失败: 响应体为空"); return false; } } catch (Exception e) { log.error("发送钉钉机器人消息异常", e); return false; } } /** * 生成签名 */ private String generateSign(long timestamp, String secret) throws Exception { String stringToSign = timestamp + "\n" + secret; Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); byte[] signData = mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8)); return URLEncoder.encode(Base64.getEncoder().encodeToString(signData), StandardCharsets.UTF_8.toString()); } /** * 获取部门列表 */ public List> getDepartmentList() { DingTalkConfig config = getConfig(); if (config == null) { return Collections.emptyList(); } try { String accessToken = getAccessToken(config); if (accessToken == null) { return Collections.emptyList(); } String url = String.format("%s/topapi/v2/department/listsub?access_token=%s", DINGTALK_API_BASE, accessToken); Map request = new HashMap<>(); request.put("dept_id", 1); // 根部门 HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity> entity = new HttpEntity<>(request, headers); ResponseEntity response = restTemplate.postForEntity(url, entity, Map.class); Map body = response.getBody(); if (body != null) { Object errcode = body.get("errcode"); if (errcode != null && (errcode.equals(0) || errcode.equals(0L) || "0".equals(errcode.toString()))) { return (List>) body.get("result"); } else { log.error("获取钉钉部门列表失败: errcode={}, errmsg={}", errcode, body.get("errmsg")); } } return Collections.emptyList(); } catch (Exception e) { log.error("获取钉钉部门列表异常", e); return Collections.emptyList(); } } /** * 钉钉OAuth登录 - 获取用户访问令牌 * 参考:https://open.dingtalk.com/document/orgapp/obtain-user-token */ public Map getUserAccessToken(String code) { DingTalkConfig config = getConfig(); if (!isConfigured()) { throw new BusinessException(ResultCode.PARAM_ERROR, "钉钉登录未配置或未启用,请联系管理员在系统设置中配置"); } try { String url = "https://api.dingtalk.com/v1.0/oauth2/userAccessToken"; Map request = new HashMap<>(); request.put("clientId", config.getAppKey()); request.put("clientSecret", config.getAppSecret()); request.put("code", code); request.put("grantType", "authorization_code"); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity> entity = new HttpEntity<>(request, headers); ResponseEntity response = restTemplate.postForEntity(url, entity, Map.class); Map body = response.getBody(); log.info("获取用户访问令牌响应: {}", body); return body; } catch (Exception e) { log.error("获取用户访问令牌失败", e); throw new BusinessException(ResultCode.DINGTALK_API_ERROR, "获取钉钉用户令牌失败: " + e.getMessage()); } } /** * 通过临时授权码获取用户信息(应用内免登专用) * 使用 /topapi/v2/user/getuserinfo 接口 * 参考:https://open.dingtalk.com/document/orgapp/obtain-the-userid-of-a-user-by-using-the-log-free */ public Map getUserInfoByAuthCode(String authCode) { DingTalkConfig config = getConfig(); if (!isConfigured()) { throw new BusinessException(ResultCode.PARAM_ERROR, "钉钉登录未配置或未启用"); } String accessToken = getAccessToken(config); try { String url = String.format("%s/topapi/v2/user/getuserinfo?access_token=%s", DINGTALK_API_BASE, accessToken); Map request = new HashMap<>(); request.put("code", authCode); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity> entity = new HttpEntity<>(request, headers); ResponseEntity response = restTemplate.postForEntity(url, entity, Map.class); Map body = response.getBody(); log.info("应用内免登获取用户信息响应: {}", body); if (body != null && Integer.valueOf(0).equals(body.get("errcode"))) { Map result = (Map) body.get("result"); if (result != null) { return result; } } String errMsg = body != null ? String.valueOf(body.get("errmsg")) : "未知错误"; throw new BusinessException(ResultCode.DINGTALK_API_ERROR, "获取用户信息失败: " + errMsg); } catch (BusinessException e) { throw e; } catch (Exception e) { log.error("应用内免登获取用户信息失败", e); throw new BusinessException(ResultCode.DINGTALK_API_ERROR, "获取钉钉用户信息失败: " + e.getMessage()); } } /** * 获取用户unionId * 参考:https://open.dingtalk.com/document/orgapp/obtain-the-user-information-based-on-the-sns-temporary-authorization */ public Map getUserInfo(String accessToken) { try { String url = "https://api.dingtalk.com/v1.0/contact/users/me"; HttpHeaders headers = new HttpHeaders(); headers.set("x-acs-dingtalk-access-token", accessToken); HttpEntity entity = new HttpEntity<>(headers); ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, Map.class); Map body = response.getBody(); log.info("获取用户信息响应: {}", body); return body; } catch (Exception e) { log.error("获取用户信息失败", e); throw new BusinessException(ResultCode.DINGTALK_API_ERROR, "获取钉钉用户信息失败: " + e.getMessage()); } } /** * 根据unionId获取userId */ public String getUserIdByUnionId(String unionId) { DingTalkConfig config = getConfig(); String accessToken = getAccessToken(config); try { String url = String.format("%s/topapi/user/getbyunionid?access_token=%s", DINGTALK_API_BASE, accessToken); Map request = new HashMap<>(); request.put("unionid", unionId); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity> entity = new HttpEntity<>(request, headers); ResponseEntity response = restTemplate.postForEntity(url, entity, Map.class); Map body = response.getBody(); if (body != null) { Object errcode = body.get("errcode"); if (errcode != null && (errcode.equals(0) || errcode.equals(0L) || "0".equals(errcode.toString()))) { Map result = (Map) body.get("result"); return (String) result.get("userid"); } else { log.error("根据unionId获取userId失败: errcode={}, errmsg={}", errcode, body.get("errmsg")); } } return null; } catch (Exception e) { log.error("根据unionId获取userId失败", e); return null; } } /** * 根据userId获取详细用户信息(包含部门信息) */ public Map getDetailedUserInfo(String userId) { DingTalkConfig config = getConfig(); String accessToken = getAccessToken(config); try { String url = String.format("%s/topapi/v2/user/get?access_token=%s", DINGTALK_API_BASE, accessToken); Map request = new HashMap<>(); request.put("userid", userId); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity> entity = new HttpEntity<>(request, headers); ResponseEntity response = restTemplate.postForEntity(url, entity, Map.class); Map body = response.getBody(); if (body != null) { Object errcode = body.get("errcode"); if (errcode != null && (errcode.equals(0) || errcode.equals(0L) || "0".equals(errcode.toString()))) { Map result = (Map) body.get("result"); // 获取部门信息 Object deptIdListObj = result.get("dept_id_list"); if (deptIdListObj != null) { List> departments = new ArrayList<>(); List deptIdList = (List) deptIdListObj; if (!deptIdList.isEmpty()) { for (Object deptIdObj : deptIdList) { // 处理Integer或Long类型 String deptIdStr = deptIdObj != null ? deptIdObj.toString() : null; if (deptIdStr != null) { Map deptInfo = getDepartmentInfo(deptIdStr); if (deptInfo != null) { departments.add(deptInfo); } } } result.put("departments", departments); log.info("获取到用户部门信息: userId={}, departments={}", userId, departments.size()); } } return result; } else { log.error("获取详细用户信息失败: errcode={}, errmsg={}", errcode, body.get("errmsg")); } } return null; } catch (Exception e) { log.error("获取详细用户信息失败", e); return null; } } /** * 获取钉钉部门信息 */ public Map getDepartmentInfo(String deptId) { DingTalkConfig config = getConfig(); String accessToken = getAccessToken(config); try { String url = String.format("%s/topapi/v2/department/get?access_token=%s", DINGTALK_API_BASE, accessToken); Map request = new HashMap<>(); request.put("dept_id", Long.parseLong(deptId)); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity> entity = new HttpEntity<>(request, headers); ResponseEntity response = restTemplate.postForEntity(url, entity, Map.class); Map body = response.getBody(); if (body != null) { Object errcode = body.get("errcode"); if (errcode != null && (errcode.equals(0) || errcode.equals(0L) || "0".equals(errcode.toString()))) { return (Map) body.get("result"); } else { log.error("获取钉钉部门信息失败: deptId={}, errcode={}, errmsg={}", deptId, errcode, body.get("errmsg")); } } return null; } catch (Exception e) { log.error("获取钉钉部门信息失败: deptId={}", deptId, e); return null; } } /** * 同步钉钉部门到系统数据库 * 递归获取所有部门,保存或更新到数据库,并设置ding_dept_id */ @Transactional(rollbackFor = Exception.class) public int syncDepartments() { DingTalkConfig config = getConfig(); if (config == null) { throw new BusinessException(ResultCode.PARAM_ERROR, "钉钉未配置"); } log.info("开始同步钉钉部门..."); int syncCount = 0; try { String accessToken = getAccessToken(config); if (accessToken == null) { throw new BusinessException(ResultCode.PARAM_ERROR, "获取钉钉访问令牌失败"); } // 递归获取所有部门(从根部门开始) List> allDepartments = getAllDepartmentsRecursive(accessToken, 1L); log.info("从钉钉获取到 {} 个部门", allDepartments.size()); // 按照层级排序:父部门在前,子部门在后 // 通过多次遍历确保父部门先创建 Map dingDeptIdToSysIdMap = new HashMap<>(); // 缓存ding_dept_id到系统ID的映射 // 第一遍:创建所有部门(如果不存在),并设置ding_dept_id和parent_id为0 for (Map dingDept : allDepartments) { Object deptIdObj = dingDept.get("dept_id"); String dingDeptId = deptIdObj != null ? deptIdObj.toString() : null; String deptName = (String) dingDept.get("name"); if (dingDeptId == null || deptName == null) { log.warn("跳过无效的部门信息: {}", dingDept); continue; } // 查找系统部门(通过ding_dept_id或名称) com.designhub.entity.Department sysDept = departmentMapper.selectOne( new LambdaQueryWrapper() .eq(com.designhub.entity.Department::getDingDeptId, dingDeptId) .eq(com.designhub.entity.Department::getDeleted, 0) .last("LIMIT 1") ); if (sysDept == null) { // 通过名称查找 sysDept = departmentMapper.selectOne( new LambdaQueryWrapper() .eq(com.designhub.entity.Department::getDeptName, deptName) .eq(com.designhub.entity.Department::getDeleted, 0) .last("LIMIT 1") ); } if (sysDept == null) { // 部门不存在,自动创建(先不设置parent_id,等第二遍再设置) try { // 根据部门名称智能判断部门类型 String deptType = inferDeptType(deptName); // 创建新部门 com.designhub.entity.Department newDept = new com.designhub.entity.Department(); newDept.setDeptName(deptName); newDept.setDingDeptId(dingDeptId); newDept.setDeptType(deptType); newDept.setParentId(0L); // 先设置为0,第二遍再更新 newDept.setStatus(1); // 启用 newDept.setSortOrder(0); departmentMapper.insert(newDept); sysDept = newDept; log.info("自动创建部门(第一遍): id={}, name={}, ding_dept_id={}, type={}", newDept.getId(), deptName, dingDeptId, deptType); } catch (Exception e) { log.error("自动创建部门失败: name={}, ding_dept_id={}", deptName, dingDeptId, e); continue; // 跳过失败的部门 } } else { // 更新ding_dept_id(如果还没有) if (!dingDeptId.equals(sysDept.getDingDeptId())) { sysDept.setDingDeptId(dingDeptId); departmentMapper.updateById(sysDept); log.info("更新部门ding_dept_id: id={}, name={}, ding_dept_id={}", sysDept.getId(), deptName, dingDeptId); } } // 缓存映射关系 dingDeptIdToSysIdMap.put(dingDeptId, sysDept.getId()); } // 第二遍:更新所有部门的parent_id for (Map dingDept : allDepartments) { Object deptIdObj = dingDept.get("dept_id"); String dingDeptId = deptIdObj != null ? deptIdObj.toString() : null; String deptName = (String) dingDept.get("name"); Object parentIdObj = dingDept.get("parent_id"); String parentDingDeptId = parentIdObj != null ? parentIdObj.toString() : null; if (dingDeptId == null || deptName == null) { continue; } Long sysDeptId = dingDeptIdToSysIdMap.get(dingDeptId); if (sysDeptId == null) { continue; // 第一遍创建失败的部门跳过 } // 获取系统部门 com.designhub.entity.Department sysDept = departmentMapper.selectById(sysDeptId); if (sysDept == null) { continue; } // 确定parent_id Long parentId = 0L; if (parentDingDeptId != null && !parentDingDeptId.equals("1")) { // 从缓存中获取父部门的系统ID Long parentSysId = dingDeptIdToSysIdMap.get(parentDingDeptId); if (parentSysId != null) { parentId = parentSysId; } } // 更新parent_id(如果不同) Long currentParentId = sysDept.getParentId() != null ? sysDept.getParentId() : 0L; if (!parentId.equals(currentParentId)) { sysDept.setParentId(parentId); departmentMapper.updateById(sysDept); log.info("更新部门父子关系: id={}, name={}, parentId: {} -> {}", sysDept.getId(), deptName, currentParentId, parentId); } syncCount++; } log.info("同步钉钉部门完成,共处理 {} 个部门", syncCount); return syncCount; } catch (Exception e) { log.error("同步钉钉部门失败", e); throw new BusinessException(ResultCode.DINGTALK_API_ERROR, "同步钉钉部门失败: " + e.getMessage()); } } /** * 递归获取所有部门 */ private List> getAllDepartmentsRecursive(String accessToken, Long parentDeptId) { List> allDepartments = new ArrayList<>(); try { String url = String.format("%s/topapi/v2/department/listsub?access_token=%s", DINGTALK_API_BASE, accessToken); Map request = new HashMap<>(); request.put("dept_id", parentDeptId); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity> entity = new HttpEntity<>(request, headers); ResponseEntity response = restTemplate.postForEntity(url, entity, Map.class); Map body = response.getBody(); if (body != null) { Object errcode = body.get("errcode"); if (errcode != null && (errcode.equals(0) || errcode.equals(0L) || "0".equals(errcode.toString()))) { List> subDepts = (List>) body.get("result"); if (subDepts != null) { for (Map dept : subDepts) { allDepartments.add(dept); // 递归获取子部门 Object deptIdObj = dept.get("dept_id"); if (deptIdObj != null) { Long deptId = deptIdObj instanceof Long ? (Long) deptIdObj : Long.parseLong(deptIdObj.toString()); allDepartments.addAll(getAllDepartmentsRecursive(accessToken, deptId)); } } } } } } catch (Exception e) { log.error("获取部门列表失败: parentDeptId={}", parentDeptId, e); } return allDepartments; } /** * 根据部门名称推断部门类型 * 可以根据实际业务规则调整判断逻辑 */ private String inferDeptType(String deptName) { if (deptName == null) { return "demand"; // 默认需求部门 } String name = deptName.toLowerCase(); // 常见的接单部门关键词 if (name.contains("设计") || name.contains("创意") || name.contains("美工") || name.contains("视觉") || name.contains("ui") || name.contains("ux")) { return "receiving"; } // 默认需求部门 return "demand"; } /** * 同步钉钉组织架构(用户部门信息) * 注意:只同步用户的部门信息,不覆盖管理员手动设置的部门负责人 */ @Transactional public Map syncOrganization() { DingTalkConfig config = getConfig(); if (config == null || !isConfigured()) { throw new BusinessException(ResultCode.PARAM_ERROR, "钉钉未配置或未启用"); } log.info("开始同步钉钉组织架构..."); int updatedUsers = 0; int skippedUsers = 0; int errorUsers = 0; int disabledUsers = 0; // 已离职被禁用的用户数 List errors = new ArrayList<>(); try { String accessToken = getAccessToken(config); if (accessToken == null) { throw new BusinessException(ResultCode.DINGTALK_API_ERROR, "获取钉钉访问令牌失败"); } // 获取系统中所有有钉钉ID的用户 List users = userMapper.selectList( new LambdaQueryWrapper() .isNotNull(User::getDingUserId) .ne(User::getDingUserId, "") .eq(User::getStatus, 1) ); log.info("找到 {} 个钉钉用户需要同步", users.size()); for (User user : users) { try { // 获取用户在钉钉中的部门信息 Map dingUserInfo = getDingUserDetail(accessToken, user.getDingUserId()); if (dingUserInfo != null) { // 检查是否是"找不到用户"的错误(已离职) Object errcode = dingUserInfo.get("_errcode"); if (errcode != null && "60121".equals(errcode.toString())) { // 用户在钉钉中不存在(已离职),禁用该用户 user.setStatus(0); userMapper.updateById(user); disabledUsers++; log.info("用户在钉钉中不存在(已离职),已禁用: userId={}, userName={}", user.getId(), user.getRealName()); continue; } // 获取用户的主部门ID Object deptIdListObj = dingUserInfo.get("dept_id_list"); if (deptIdListObj instanceof List && !((List) deptIdListObj).isEmpty()) { List deptIdList = (List) deptIdListObj; Long dingDeptId = deptIdList.get(0).longValue(); // 取第一个作为主部门 // 查找系统中对应的部门 Department sysDept = departmentMapper.selectOne( new LambdaQueryWrapper() .eq(Department::getDingDeptId, dingDeptId.toString()) .eq(Department::getStatus, 1) ); if (sysDept != null) { // 检查用户部门是否需要更新 if (!sysDept.getId().equals(user.getDeptId())) { user.setDeptId(sysDept.getId()); user.setDeptName(sysDept.getDeptName()); userMapper.updateById(user); updatedUsers++; log.info("更新用户部门: userId={}, userName={}, newDeptId={}, newDeptName={}", user.getId(), user.getRealName(), sysDept.getId(), sysDept.getDeptName()); } else { skippedUsers++; } } else { // 系统中没有对应部门,自动创建 Map dingDeptInfo = getDingDepartmentDetail(accessToken, dingDeptId); if (dingDeptInfo != null) { String deptName = (String) dingDeptInfo.get("name"); Object parentIdObj = dingDeptInfo.get("parent_id"); Long dingParentId = parentIdObj != null ? Long.parseLong(parentIdObj.toString()) : 1L; // 创建新部门 Department newDept = createDepartmentFromDingTalk(accessToken, dingDeptId, deptName, dingParentId); if (newDept != null) { // 更新用户部门 user.setDeptId(newDept.getId()); user.setDeptName(newDept.getDeptName()); userMapper.updateById(user); updatedUsers++; log.info("自动创建部门并更新用户: userId={}, userName={}, newDeptId={}, newDeptName={}", user.getId(), user.getRealName(), newDept.getId(), newDept.getDeptName()); } else { errors.add(String.format("用户 %s 的钉钉部门 %s 创建失败", user.getRealName(), deptName)); errorUsers++; } } else { errorUsers++; } } } else { skippedUsers++; log.debug("用户 {} 在钉钉中没有部门信息", user.getRealName()); } } else { errorUsers++; errors.add(String.format("无法获取用户 %s 的钉钉信息", user.getRealName())); } } catch (Exception e) { errorUsers++; errors.add(String.format("同步用户 %s 失败: %s", user.getRealName(), e.getMessage())); log.error("同步用户 {} 失败", user.getRealName(), e); } } } catch (Exception e) { log.error("同步钉钉组织架构失败", e); throw new BusinessException(ResultCode.DINGTALK_API_ERROR, "同步失败: " + e.getMessage()); } Map result = new HashMap<>(); result.put("updatedUsers", updatedUsers); result.put("skippedUsers", skippedUsers); result.put("errorUsers", errorUsers); result.put("disabledUsers", disabledUsers); result.put("errors", errors); log.info("钉钉组织架构同步完成: 更新{}人, 跳过{}人, 禁用{}人(已离职), 失败{}人", updatedUsers, skippedUsers, disabledUsers, errorUsers); return result; } /** * 获取钉钉用户详情 * 注意:如果用户不存在(errcode=60121),会返回包含 _errcode 字段的 Map */ private Map getDingUserDetail(String accessToken, String dingUserId) { try { String url = String.format("%s/topapi/v2/user/get?access_token=%s", DINGTALK_API_BASE, accessToken); Map request = new HashMap<>(); request.put("userid", dingUserId); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity> entity = new HttpEntity<>(request, headers); ResponseEntity response = restTemplate.postForEntity(url, entity, Map.class); Map body = response.getBody(); if (body != null) { Object errcode = body.get("errcode"); if (errcode != null && (errcode.equals(0) || errcode.equals(0L) || "0".equals(errcode.toString()))) { return (Map) body.get("result"); } else { log.warn("获取钉钉用户详情失败: userId={}, errcode={}, errmsg={}", dingUserId, errcode, body.get("errmsg")); // 如果是"找不到用户"的错误,返回包含错误码的 Map,便于调用方判断 if (errcode != null && ("60121".equals(errcode.toString()) || errcode.equals(60121))) { Map errorResult = new HashMap<>(); errorResult.put("_errcode", "60121"); errorResult.put("_errmsg", "找不到该用户"); return errorResult; } } } } catch (Exception e) { log.error("获取钉钉用户详情异常: userId={}", dingUserId, e); } return null; } /** * 获取钉钉部门详情 */ private Map getDingDepartmentDetail(String accessToken, Long deptId) { try { String url = String.format("%s/topapi/v2/department/get?access_token=%s", DINGTALK_API_BASE, accessToken); Map request = new HashMap<>(); request.put("dept_id", deptId); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity> entity = new HttpEntity<>(request, headers); ResponseEntity response = restTemplate.postForEntity(url, entity, Map.class); Map body = response.getBody(); if (body != null) { Object errcode = body.get("errcode"); if (errcode != null && (errcode.equals(0) || errcode.equals(0L) || "0".equals(errcode.toString()))) { return (Map) body.get("result"); } } } catch (Exception e) { log.error("获取钉钉部门详情异常: deptId={}", deptId, e); } return null; } /** * 从钉钉部门信息创建系统部门 * 注意:不设置部门负责人,保留给管理员手动设置 */ private Department createDepartmentFromDingTalk(String accessToken, Long dingDeptId, String deptName, Long dingParentId) { try { // 查找父部门 Long sysParentId = 0L; if (dingParentId != null && dingParentId > 1) { Department parentDept = departmentMapper.selectOne( new LambdaQueryWrapper() .eq(Department::getDingDeptId, dingParentId.toString()) .eq(Department::getStatus, 1) ); if (parentDept != null) { sysParentId = parentDept.getId(); } else { // 递归创建父部门 Map parentDingDeptInfo = getDingDepartmentDetail(accessToken, dingParentId); if (parentDingDeptInfo != null) { String parentName = (String) parentDingDeptInfo.get("name"); Object grandParentIdObj = parentDingDeptInfo.get("parent_id"); Long grandParentId = grandParentIdObj != null ? Long.parseLong(grandParentIdObj.toString()) : 1L; Department createdParent = createDepartmentFromDingTalk(accessToken, dingParentId, parentName, grandParentId); if (createdParent != null) { sysParentId = createdParent.getId(); } } } } // 推断部门类型 String deptType = inferDeptType(deptName); // 如果有父部门,继承父部门的类型 if (sysParentId > 0) { Department parentDept = departmentMapper.selectById(sysParentId); if (parentDept != null && parentDept.getDeptType() != null) { deptType = parentDept.getDeptType(); } } // 创建新部门 Department newDept = new Department(); newDept.setDeptName(deptName); newDept.setDingDeptId(dingDeptId.toString()); newDept.setParentId(sysParentId); newDept.setDeptType(deptType); newDept.setStatus(1); newDept.setSortOrder(0); // 注意:不设置 leaderId,保留给管理员手动设置 departmentMapper.insert(newDept); log.info("自动创建部门: id={}, name={}, dingDeptId={}, parentId={}, deptType={}", newDept.getId(), deptName, dingDeptId, sysParentId, deptType); return newDept; } catch (Exception e) { log.error("创建部门失败: dingDeptId={}, deptName={}", dingDeptId, deptName, e); return null; } } /** * 检查功能是否启用 */ public boolean isFeatureEnabled(String feature) { DingTalkConfig config = getConfig(); // 检查配置是否存在、已启用,并且enabledFeatures不为空 if (config == null || config.getEnabled() == null || config.getEnabled() != 1 || config.getEnabledFeatures() == null || config.getEnabledFeatures().trim().isEmpty()) { return false; } try { Map features = objectMapper.readValue( config.getEnabledFeatures(), new TypeReference>() {}); return features.getOrDefault(feature, false); } catch (Exception e) { log.warn("解析enabledFeatures失败: {}", e.getMessage()); return false; } } /** * 发送任务通知(如果启用钉钉通知) */ public void sendTaskNotification(String dingTalkUserId, String taskTitle, String action) { if (!isFeatureEnabled("notification") || dingTalkUserId == null) { return; } String title = "任务通知"; String content = String.format("您的任务「%s」%s", taskTitle, action); sendWorkNotification(Collections.singletonList(dingTalkUserId), title, content); } }