1240 lines
54 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<DingTalkConfig> 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<Map> response = restTemplate.getForEntity(url, Map.class);
Map<String, Object> 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<String, Object> request = new HashMap<>();
request.put("mobile", mobile);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(request, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(url, entity, Map.class);
Map<String, Object> 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<String, Object> result = (Map<String, Object>) 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<String> 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<String> 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<String, Object> msg = new HashMap<>();
msg.put("msgtype", "action_card");
Map<String, String> 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<String, Object> 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<Map<String, Object>> entity = new HttpEntity<>(request, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(url, entity, Map.class);
Map<String, Object> 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<String> 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 + "&timestamp=" + timestamp + "&sign=" + sign;
}
// 获取官网地址并构建钉钉应用内跳转链接
String siteUrl = getSiteUrl();
String jumpUrl = buildDingTalkAppLink(config, siteUrl);
// 构建 Markdown 消息(群机器人支持的格式)
Map<String, Object> message = new HashMap<>();
message.put("msgtype", "markdown");
Map<String, Object> 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<String, Object> 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<Map<String, Object>> entity = new HttpEntity<>(message, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(webhook, entity, Map.class);
Map<String, Object> 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<Map<String, Object>> 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<String, Object> request = new HashMap<>();
request.put("dept_id", 1); // 根部门
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(request, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(url, entity, Map.class);
Map<String, Object> 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<Map<String, Object>>) 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<String, Object> 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<String, Object> 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<Map<String, Object>> entity = new HttpEntity<>(request, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(url, entity, Map.class);
Map<String, Object> 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<String, Object> 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<String, Object> request = new HashMap<>();
request.put("code", authCode);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(request, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(url, entity, Map.class);
Map<String, Object> body = response.getBody();
log.info("应用内免登获取用户信息响应: {}", body);
if (body != null && Integer.valueOf(0).equals(body.get("errcode"))) {
Map<String, Object> result = (Map<String, Object>) 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<String, Object> 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<String> entity = new HttpEntity<>(headers);
ResponseEntity<Map> response = restTemplate.exchange(url, HttpMethod.GET, entity, Map.class);
Map<String, Object> 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<String, Object> request = new HashMap<>();
request.put("unionid", unionId);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(request, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(url, entity, Map.class);
Map<String, Object> 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<String, Object> result = (Map<String, Object>) 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<String, Object> 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<String, Object> request = new HashMap<>();
request.put("userid", userId);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(request, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(url, entity, Map.class);
Map<String, Object> 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<String, Object> result = (Map<String, Object>) body.get("result");
// 获取部门信息
Object deptIdListObj = result.get("dept_id_list");
if (deptIdListObj != null) {
List<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> request = new HashMap<>();
request.put("dept_id", Long.parseLong(deptId));
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(request, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(url, entity, Map.class);
Map<String, Object> 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<String, Object>) 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<Map<String, Object>> allDepartments = getAllDepartmentsRecursive(accessToken, 1L);
log.info("从钉钉获取到 {} 个部门", allDepartments.size());
// 按照层级排序:父部门在前,子部门在后
// 通过多次遍历确保父部门先创建
Map<String, Long> dingDeptIdToSysIdMap = new HashMap<>(); // 缓存ding_dept_id到系统ID的映射
// 第一遍:创建所有部门(如果不存在),并设置ding_dept_id和parent_id为0
for (Map<String, Object> 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<com.designhub.entity.Department>()
.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<com.designhub.entity.Department>()
.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<String, Object> 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<Map<String, Object>> getAllDepartmentsRecursive(String accessToken, Long parentDeptId) {
List<Map<String, Object>> allDepartments = new ArrayList<>();
try {
String url = String.format("%s/topapi/v2/department/listsub?access_token=%s",
DINGTALK_API_BASE, accessToken);
Map<String, Object> request = new HashMap<>();
request.put("dept_id", parentDeptId);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(request, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(url, entity, Map.class);
Map<String, Object> 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<Map<String, Object>> subDepts = (List<Map<String, Object>>) body.get("result");
if (subDepts != null) {
for (Map<String, Object> 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<String, Object> 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<String> errors = new ArrayList<>();
try {
String accessToken = getAccessToken(config);
if (accessToken == null) {
throw new BusinessException(ResultCode.DINGTALK_API_ERROR, "获取钉钉访问令牌失败");
}
// 获取系统中所有有钉钉ID的用户
List<User> users = userMapper.selectList(
new LambdaQueryWrapper<User>()
.isNotNull(User::getDingUserId)
.ne(User::getDingUserId, "")
.eq(User::getStatus, 1)
);
log.info("找到 {} 个钉钉用户需要同步", users.size());
for (User user : users) {
try {
// 获取用户在钉钉中的部门信息
Map<String, Object> 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<Integer> deptIdList = (List<Integer>) deptIdListObj;
Long dingDeptId = deptIdList.get(0).longValue(); // 取第一个作为主部门
// 查找系统中对应的部门
Department sysDept = departmentMapper.selectOne(
new LambdaQueryWrapper<Department>()
.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<String, Object> 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<String, Object> 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<String, Object> getDingUserDetail(String accessToken, String dingUserId) {
try {
String url = String.format("%s/topapi/v2/user/get?access_token=%s", DINGTALK_API_BASE, accessToken);
Map<String, Object> request = new HashMap<>();
request.put("userid", dingUserId);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(request, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(url, entity, Map.class);
Map<String, Object> 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<String, Object>) 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<String, Object> errorResult = new HashMap<>();
errorResult.put("_errcode", "60121");
errorResult.put("_errmsg", "找不到该用户");
return errorResult;
}
}
}
} catch (Exception e) {
log.error("获取钉钉用户详情异常: userId={}", dingUserId, e);
}
return null;
}
/**
* 获取钉钉部门详情
*/
private Map<String, Object> getDingDepartmentDetail(String accessToken, Long deptId) {
try {
String url = String.format("%s/topapi/v2/department/get?access_token=%s", DINGTALK_API_BASE, accessToken);
Map<String, Object> request = new HashMap<>();
request.put("dept_id", deptId);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(request, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(url, entity, Map.class);
Map<String, Object> 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<String, Object>) 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<Department>()
.eq(Department::getDingDeptId, dingParentId.toString())
.eq(Department::getStatus, 1)
);
if (parentDept != null) {
sysParentId = parentDept.getId();
} else {
// 递归创建父部门
Map<String, Object> 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<String, Boolean> features = objectMapper.readValue(
config.getEnabledFeatures(), new TypeReference<Map<String, Boolean>>() {});
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);
}
}