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.mapper.DepartmentMapper; import com.designhub.mapper.DingTalkConfigMapper; 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 static final String DINGTALK_API_BASE = "https://oapi.dingtalk.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; } } /** * 发送工作通知 */ public boolean sendWorkNotification(List userIds, String title, String content) { 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); Map msg = new HashMap<>(); msg.put("msgtype", "action_card"); Map actionCard = new HashMap<>(); actionCard.put("title", title); actionCard.put("markdown", content); actionCard.put("single_title", "查看详情"); actionCard.put("single_url", "dingtalk://dingtalkclient/page/link?url=" + URLEncoder.encode("https://your-domain.com", StandardCharsets.UTF_8.toString())); 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={}", userIds); 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) { 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; } Map message = new HashMap<>(); message.put("msgtype", "markdown"); Map markdown = new HashMap<>(); markdown.put("title", title); markdown.put("text", "### " + title + "\n\n" + content); message.put("markdown", markdown); 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("钉钉机器人消息发送成功: 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()); } } /** * 获取用户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()); // 同步每个部门到数据库 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) { 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) { // 更新现有部门:设置ding_dept_id sysDept.setDingDeptId(dingDeptId); departmentMapper.updateById(sysDept); log.info("更新部门: id={}, name={}, ding_dept_id={}", sysDept.getId(), deptName, dingDeptId); } else { // 部门不存在,自动创建 try { Long parentId = 0L; if (parentDingDeptId != null && !parentDingDeptId.equals("1")) { // 查找父部门的系统ID com.designhub.entity.Department parentDept = departmentMapper.selectOne( new LambdaQueryWrapper() .eq(com.designhub.entity.Department::getDingDeptId, parentDingDeptId) .eq(com.designhub.entity.Department::getDeleted, 0) .last("LIMIT 1") ); if (parentDept != null) { parentId = parentDept.getId(); } } // 根据部门名称智能判断部门类型(可根据实际业务规则调整) 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(parentId); newDept.setStatus(1); // 启用 newDept.setSortOrder(0); departmentMapper.insert(newDept); // 初始化积分池会在DepartmentService.createDepartment中处理 // 这里直接调用createDepartment会触发积分池初始化 // 但为了避免循环依赖,我们直接使用mapper插入,积分池可以稍后初始化 // 如果需要积分池,可以在后台手动初始化,或者用户首次使用时会自动创建 log.info("自动创建部门: id={}, name={}, ding_dept_id={}, type={}, parentId={}", newDept.getId(), deptName, dingDeptId, deptType, parentId); } catch (Exception e) { log.error("自动创建部门失败: name={}, ding_dept_id={}", deptName, dingDeptId, e); // 创建失败不影响其他部门的同步,继续处理下一个 } } 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; } /** * 同步钉钉用户 */ public int syncUsers() { DingTalkConfig config = getConfig(); if (config == null) { throw new BusinessException(ResultCode.PARAM_ERROR, "钉钉未配置"); } // 获取用户列表并同步到系统 // 这里需要根据实际需求实现用户同步逻辑 log.info("开始同步钉钉用户..."); // TODO: 实现用户同步逻辑 return 0; } /** * 检查功能是否启用 */ 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); } }