package com.ruoyi.project.service.impl; import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; import com.baomidou.mybatisplus.core.toolkit.ObjectUtils; import com.dingtalk.api.DefaultDingTalkClient; import com.dingtalk.api.DingTalkClient; import com.dingtalk.api.request.*; import com.dingtalk.api.response.*; import com.fasterxml.jackson.databind.ObjectMapper; import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.common.exception.base.BaseException; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.http.HttpUtils; import com.ruoyi.project.domain.vo.DingDetpInfoVo; import com.ruoyi.project.domain.vo.DingTalkReqVo; import com.ruoyi.project.domain.vo.DingUserInfoVo; import com.ruoyi.project.service.DingTalkService; import com.ruoyi.system.mapper.SysUserMapper; import com.taobao.api.ApiException; import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.CollectionUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** * 智能中心 * * @author ls * @date 2023-05-23 */ @Service @Slf4j public class DingTalkServiceImpl implements DingTalkService { @Value("${dingAppid}") private String dingAppid; @Value("${dingAppSecret}") private String dingAppSecret; @Value("${agentId}") private Long agentId; /** * access_token 缓存。 * 钉钉 token 有效期 7200 秒,这里按 6000 秒保守刷新; * 原实现每次发消息都调一次 /gettoken,钉钉对取 token 有频次限制,审核高峰期容易整体失败。 */ private volatile String cachedAccessToken; /** access_token 过期时间(epoch millis) */ private volatile long accessTokenExpireAt; private static final long ACCESS_TOKEN_TTL_MILLIS = 6000L * 1000L; /** * 钉钉接口超时(毫秒)。 * SDK 未显式设置超时时会无限等待,钉钉网络不通将占满容器线程、拖死审批等调用方, * 因此这里对所有出站请求统一设置连接/读取超时。 */ private static final int CONNECT_TIMEOUT_MS = 3000; private static final int READ_TIMEOUT_MS = 5000; @Autowired private SysUserMapper sysUserMapper; /** * 创建带超时的钉钉客户端 */ private DefaultDingTalkClient newClient(String url) { DefaultDingTalkClient client = new DefaultDingTalkClient(url); client.setConnectTimeout(CONNECT_TIMEOUT_MS); client.setReadTimeout(READ_TIMEOUT_MS); return client; } /** * 发送钉钉通知(对外唯一入口)。 * *

本方法保证不向外抛任何异常:钉钉属于旁路通知,任何失败都只记日志并返回 false, * 绝不能影响审批、上报等业务主流程。 */ @Override public Boolean sendNotification(DingTalkReqVo dingTalkReqVo) { try { return doSendNotification(dingTalkReqVo); } catch (Throwable t) { log.error("钉钉通知发送发生未预期异常,已忽略(不影响业务主流程)", t); return false; } } private Boolean doSendNotification(DingTalkReqVo dingTalkReqVo) { List userIdlist = new ArrayList(); if (dingTalkReqVo == null) { log.warn("钉钉通知入参为空,跳过发送"); return false; } log.info("发送钉钉通知,title={}", dingTalkReqVo.getTitle()); String accessToken = null; try { accessToken = getAccessToken(); } catch (Exception e) { log.error("钉钉获取 access_token 失败,无法发送通知", e); } if (StringUtils.isEmpty(accessToken)) { log.error("钉钉 access_token 为空,无法发送通知"); return false; } // ---- 解析收件人:优先手机号,其次部门 ---- if (StringUtils.isNotEmpty(dingTalkReqVo.getNumber())) { try { DingTalkClient clientT = newClient("https://oapi.dingtalk.com/topapi/v2/user/getbymobile"); OapiV2UserGetbymobileRequest req = new OapiV2UserGetbymobileRequest(); req.setMobile(dingTalkReqVo.getNumber()); OapiV2UserGetbymobileResponse rsp = clientT.execute(req, accessToken); String body = rsp == null ? null : rsp.getBody(); if (StringUtils.isEmpty(body)) { log.error("钉钉按手机号查用户失败,mobile={},响应为空", dingTalkReqVo.getNumber()); return false; } JSONObject resultObj = JSONObject.parseObject(body).getJSONObject("result"); if (resultObj == null || resultObj.get("userid") == null) { log.error("钉钉按手机号查用户失败,mobile={},响应={}", dingTalkReqVo.getNumber(), body); return false; } userIdlist.add(resultObj.get("userid").toString()); } catch (Exception e) { log.error("钉钉按手机号查用户异常,mobile={}", dingTalkReqVo.getNumber(), e); return false; } } else if (dingTalkReqVo.getDeptId() != null) { try { DingTalkClient client3 = newClient("https://oapi.dingtalk.com/topapi/user/listid"); OapiUserListidRequest request2 = new OapiUserListidRequest(); request2.setDeptId(dingTalkReqVo.getDeptId()); // 设置要获取的部门 ID,这里以根部门为例675909059L // 设置部门 ID,如果获取所有员工手机号,可以设置为根部门的 ID OapiUserListidResponse response2 = client3.execute(request2, accessToken); String body = response2 == null ? null : response2.getBody(); if (StringUtils.isNotEmpty(body)) { // 解析为JSONObject JSONObject jsonObject = JSONObject.parseObject(body); // 提取出JSONArray JSONArray jsonArray = new JSONArray(jsonObject.getJSONObject("result").getJSONArray("userid_list")); // 将JSONArray转为List列表 String str = JSONObject.toJSONString(jsonArray); userIdlist = JSONObject.parseObject(str, List.class); } } catch (Exception e) { log.error("钉钉按部门查用户异常,deptId={}", dingTalkReqVo.getDeptId(), e); return false; } } else { // 既无手机号也无部门:原实现会走 listid(null) 被钉钉拒绝且异常被吞掉,表现为"静默发不出去" log.warn("钉钉通知未提供收件人(手机号与部门均为空),跳过发送"); return false; } if (CollectionUtils.isEmpty(userIdlist)) { log.warn("钉钉通知未解析到任何收件人,跳过发送"); return false; } // ---- 组装消息体(所有收件人共用) ---- // contents 中每行一个单键 map,用 List 顺序保证钉钉表单行序 List objects = new ArrayList<>(); List> contents = dingTalkReqVo.getContents(); if (contents != null) { for (ConcurrentHashMap map : contents) { if (map == null) { continue; } for (Map.Entry entry : map.entrySet()) { Object value = entry.getValue(); if (value == null) { continue; } OapiMessageCorpconversationAsyncsendV2Request.Form formSeverityDesc = new OapiMessageCorpconversationAsyncsendV2Request.Form(); formSeverityDesc.setKey(entry.getKey()); if (entry.getKey().contains("审批时间") && value instanceof Date) { // 24 小时制:原实现用 hh(12 小时制且无 AM/PM),下午的时间会显示成 01:xx SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); formSeverityDesc.setValue(simpleDateFormat.format((Date) value)); } else { formSeverityDesc.setValue(value.toString()); } objects.add(formSeverityDesc); } } } // ---- 逐人发送;全部成功才算成功(原实现 result 写在循环内,只反映最后一个收件人) ---- boolean allSuccess = true; for (String urid : userIdlist) { OapiMessageCorpconversationAsyncsendV2Response response = null; try { DingTalkClient client = newClient("https://oapi.dingtalk.com/topapi/message/corpconversation/asyncsend_v2"); OapiMessageCorpconversationAsyncsendV2Request request = new OapiMessageCorpconversationAsyncsendV2Request(); request.setUseridList(urid); request.setAgentId(agentId); request.setToAllUser(false); OapiMessageCorpconversationAsyncsendV2Request.Msg msg = new OapiMessageCorpconversationAsyncsendV2Request.Msg(); msg.setOa(new OapiMessageCorpconversationAsyncsendV2Request.OA()); msg.getOa().setMessageUrl(dingTalkReqVo.getUrl()); msg.getOa().setPcMessageUrl(dingTalkReqVo.getUrl()); msg.getOa().setHead(new OapiMessageCorpconversationAsyncsendV2Request.Head()); msg.getOa().getHead().setBgcolor("00409eff"); msg.getOa().setBody(new OapiMessageCorpconversationAsyncsendV2Request.Body()); msg.getOa().getBody().setTitle(dingTalkReqVo.getTitle()); msg.getOa().getBody().setForm(objects); msg.setMsgtype("oa"); request.setMsg(msg); response = client.execute(request, accessToken); } catch (Exception e) { log.error("钉钉消息发送异常,收件人={}", urid, e); } if (response == null || !response.isSuccess()) { allSuccess = false; log.error("钉钉消息发送失败,收件人={},响应={}", urid, response == null ? "null" : response.getBody()); // 失败可能是 access_token 失效,清掉缓存让下次重新获取 clearAccessToken(); } else { log.info("钉钉消息发送成功,收件人={},taskId={}", urid, response.getTaskId()); } } return allSuccess; } /** * 免登陆接口 * * @param authCode * @return */ public Map noLogin(String authCode) { try { String urlString = "https://oapi.dingtalk.com/topapi/v2/user/getuserinfo"; String params = "access_token=" + getAccessToken() + "&code=" + authCode; String result = HttpUtils.sendPost(urlString, params); ObjectMapper objectMapper = new ObjectMapper(); Map map = objectMapper.readValue(result, Map.class); return map; } catch (Exception e) { log.error(e.getMessage()); new BaseException("登录出异常了,请联系管理员处理"); } return null; } @Override public void deptidList(Long deptID, Integer begin) { try { //根据传过来的部门ID,去获取该部门下的用户信息 if (begin != 1) DingUserInfoList(deptID); //获取子部门的dept_id DingTalkClient client = newClient("https://oapi.dingtalk.com/topapi/v2/department/listsub"); OapiV2DepartmentListsubRequest req = new OapiV2DepartmentListsubRequest(); req.setDeptId(deptID); req.setLanguage("zh_CN"); OapiV2DepartmentListsubResponse rsp = client.execute(req, getAccessToken()); ObjectMapper objectMapper = new ObjectMapper(); Map map = objectMapper.readValue(rsp.getBody(), Map.class); if (ObjectUtils.isNotEmpty(map)) { List> dingDetpInfoList = (List>) map.get("result"); if (Integer.valueOf(map.get("errcode").toString()) == 0) { for (int i = 0; i < dingDetpInfoList.size(); i++) { Map map1 = dingDetpInfoList.get(i); deptidList(Long.valueOf(map1.get("dept_id").toString()), 2); } } } } catch (Exception e) { log.error(e.getMessage()); new BaseException("登录出异常了,请联系管理员处理"); } } public void DingUserInfoList(Long deptId) { DingTalkClient client = null; try { client = newClient("https://oapi.dingtalk.com/topapi/v2/user/list?access_token=" + getAccessToken()); OapiV2UserListRequest req = new OapiV2UserListRequest(); req.setDeptId(deptId); req.setCursor(0L); req.setSize(100L); req.setLanguage("zh_CN"); OapiV2UserListResponse rsp = client.execute(req, ""); //获取resp里的用户信息集合 ObjectMapper objectMapper = new ObjectMapper(); Map map = objectMapper.readValue(rsp.getBody(), Map.class); Map mapResult = (Map) map.get("result"); List> userInfoMapList = (List>) mapResult.get("list"); if (!CollectionUtils.isEmpty(userInfoMapList)) { //根据手机号,将userID写进对应的字段上 for (Map objectMap : userInfoMapList) { SysUser sysUser = new SysUser(); sysUser.setPhonenumber(objectMap.get("mobile").toString()); sysUser.setDingUserId(objectMap.get("userid").toString()); sysUserMapper.updateUser(sysUser); } } } catch (Exception e) { e.printStackTrace(); } } /** * 获取 access_token(带缓存,失效自动重取) */ private String getAccessToken() throws ApiException { String token = cachedAccessToken; if (StringUtils.isNotEmpty(token) && System.currentTimeMillis() < accessTokenExpireAt) { return token; } synchronized (this) { if (StringUtils.isNotEmpty(cachedAccessToken) && System.currentTimeMillis() < accessTokenExpireAt) { return cachedAccessToken; } DefaultDingTalkClient client = newClient("https://oapi.dingtalk.com/gettoken"); OapiGettokenRequest request = new OapiGettokenRequest(); //Appkey request.setAppkey(dingAppid); //Appsecret request.setAppsecret(dingAppSecret); /*请求方式*/ request.setHttpMethod("GET"); OapiGettokenResponse response = client.execute(request); String newToken = response == null ? null : response.getAccessToken(); if (StringUtils.isNotEmpty(newToken)) { cachedAccessToken = newToken; accessTokenExpireAt = System.currentTimeMillis() + ACCESS_TOKEN_TTL_MILLIS; } return newToken; } } /** * 清空 access_token 缓存。 * 发送失败有可能是 token 失效(钉钉 errcode 40014/42001),清掉后下次发送会重新获取。 */ private void clearAccessToken() { cachedAccessToken = null; accessTokenExpireAt = 0L; } }