之前做过一个功能,就是把公司员工的钉钉打卡数据同步至中间库(后同步至HR系统)。
采用的方式是钉钉的时间订阅。
1、在钉钉的开发后台创建一个微应用.
2、新建应用后,进入事件与回调,根据合适的方式选择推送方式以及订阅员工打卡事件。我这边选择的是推荐的stream方式
3、不同的推送方式都有对应的文档。我这边记录一下我的推送。大家可以参考一下我的处理方式,其中包括接收数据和处理数据分离、时区转换和失败补偿。直接复用是不行的,因为有一些依赖是内部的,引入我也去掉了。
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.dingtalk.open.app.api.GenericEventListener;
import com.dingtalk.open.app.api.OpenDingTalkStreamClientBuilder;
import com.dingtalk.open.app.api.message.GenericOpenDingTalkEvent;
import com.dingtalk.open.app.api.security.AuthClientCredential;
import com.dingtalk.open.app.stream.protocol.event.EventAckStatus;
import com.google.gson.Gson;
import lombok.AllArgsConstructor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.PostConstruct;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
/**
* (VvAtdAttrecordDTO)表服务实现类
*
* @author makejava
* @since 2022-06-08 15:31:49
*/
@RequiredArgsConstructor
@Slf4j
@Service("vvAtdAttrecordService")
public class VvAtdAttrecordServiceImpl extends ServiceImpl<VvAtdAttrecordMapper, VvAtdAttrecord> implements VvAtdAttrecordService {
private final IDingTalkUserClient iDingTalkUserClient;
private final DingCheckLogService dingCheckLogService;
private final IUserClient iUserClient;
private final VvAtdAttrecordDingdingService vvAtdAttrecordDingdingService;
//钉钉考勤后台应用id 正式:dingqcwwxmoqk2sjiqdi
@Value("${dingding.clientId.dingclockin}")
private String dingClockIn;
/**
* 测试环境不允许监听(会把正式的打卡数据分流到) 所以把应用认证id配置在配置文件
* @throws Exception
*/
@PostConstruct
public void dingdingCheck() throws Exception {
//监听钉钉事件与回调
OpenDingTalkStreamClientBuilder
.custom()
.credential(new AuthClientCredential(dingClockIn, "SjV8Wo0w7rc-DVD5lkIm1SbSwDSRUfWHblNnM_JdArZomvAYy7otyvB37dSng6Xe"))
//注册事件监听
.registerAllEventListener(new GenericEventListener() {
public EventAckStatus onEvent(GenericOpenDingTalkEvent event) {
try {
LocalDate now = LocalDate.now();
System.out.println(now);
//事件唯一Id
String eventId = event.getEventId();
//事件类型
String eventType = event.getEventType();
//事件产生时间
Long bornTime = event.getEventBornTime();
//获取事件体
JSONObject bizData = event.getData();
System.out.println(bizData);
System.out.println("----------------------------");
//处理事件
process(eventId, eventType, bizData);
System.out.println("已处理");
//消费成功
return EventAckStatus.SUCCESS;
} catch (Exception e) {
//消费失败
return EventAckStatus.LATER;
}
}
})
.build().start();
}
/**
* 事件监听处理
*
* @param eventType 事件类型
* @param bizData 数据包
*/
public void process(String eventId, String eventType, JSONObject bizData) {
// 根据事件类型进行不同的处理逻辑
if (eventType.equals("attendance_check_record")) {
try {
//处理考勤打卡
JSONArray dataList = bizData.getJSONArray("dataList");
//打卡数据
JSONObject dataItem = new JSONObject();
if (ObjectUtil.isNotEmpty(dataList)) {
dataItem = dataList.getJSONObject(0);
}
LocalDateTime now = LocalDateTime.now();
DingCheckLog dingCheckLog = JSONObject.parseObject(String.valueOf(dataItem), DingCheckLog.class);
String checkTime = dingCheckLog.getCheckTime();
// 将long时间戳转换为Instant对象
Instant instant = Instant.ofEpochMilli(Long.parseLong(checkTime));
// 将Instant对象转换为LocalDateTime对象
LocalDateTime dateTime = LocalDateTime.ofInstant(instant, ZoneId.systemDefault());
dingCheckLog.setNewCheckTime(dateTime);
dingCheckLog.setSourceData(bizData.toString());
dingCheckLog.setEventId(eventId);
dingCheckLog.setUpdateTime(now);
dingCheckLog.setCreateTime(now);
dingCheckLog.setMsg("未处理");
//所有打卡数据保存
this.dingCheckLogService.save(dingCheckLog);
} catch (Exception e) {
//异常处理
this.baseMapper.updateLog(e.getMessage(), eventId);
}
} else if (eventType.equals("yyy")) {
// 处理yyy类型的事件
} else {
// 处理其他类型的事件
}
}
/**
* 时间转换
*
* @param time
* @param type
* @return
*/
public String timeConversion(String time, String type) {
// 将long时间戳转换为Instant对象
Instant instant = Instant.ofEpochMilli(Long.parseLong(time));
// 将Instant对象转换为LocalDateTime对象
LocalDateTime dateTime = LocalDateTime.ofInstant(instant, ZoneId.systemDefault());
// 创建北京时区
ZoneId beijingZoneId = ZoneId.of("Asia/Shanghai");
// 将当前时间转换为北京时间
ZonedDateTime beijingTime = dateTime.atZone(beijingZoneId);
// 创建越南时区
ZoneId vietnamZoneId = ZoneId.of("Asia/Ho_Chi_Minh");
// 创建印度时区
ZoneId indiaZoneId = ZoneId.of("Asia/Kolkata");
// 将北京时间转换为越南时间
ZonedDateTime vietnamTime = beijingTime.withZoneSameInstant(vietnamZoneId);
// 将北京时间转换为印度时间
ZonedDateTime indiaTime = beijingTime.withZoneSameInstant(indiaZoneId);
// 格式化印度&越南&北京时间
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
String indiaFormatted = indiaTime.format(formatter);
String vietnamTimeFormatted = vietnamTime.format(formatter);
String beijingTimeFormatted = beijingTime.format(formatter);
if (type.equals("india")) {
return indiaFormatted;
} else if (type.equals("vietnam")) {
return vietnamTimeFormatted;
} else {
return beijingTimeFormatted;
}
}
/**
* 处理钉钉打卡数据
*
* @return
*/
@Override
public String disposeDingFailure() {
List<String> dingFailureList = this.baseMapper.getDingFailure();
for (String dingFailure : dingFailureList) {
disposeDingCheck(dingFailure);
}
return "处理完成";
}
/**
* 处理钉钉打卡推送失败数据
* @return
*/
@Override
public String disposeDingRealFailure() {
List<DingCheckInfo> checkInfoList = iDingTalkUserClient.getFailedDingCheck().getData();
if (ObjectUtil.isEmpty(checkInfoList)){
return "无推送失败的钉钉打卡单据!";
}
LocalDateTime now = LocalDateTime.now();
for (DingCheckInfo dingCheckInfo : checkInfoList) {
DingCheckLog dingCheckLog = new DingCheckLog();
Gson gson = new Gson();
String jsonString = gson.toJson(dingCheckInfo);
dingCheckLog.setSourceData(jsonString);
dingCheckLog.setUpdateTime(now);
dingCheckLog.setCreateTime(now);
dingCheckLog.setMsg("未处理");
//保存失败打卡数据
this.dingCheckLogService.save(dingCheckLog);
}
return "成功处理"+checkInfoList.size()+"条失败数据!";
}
/**
* 同步钉钉考勤数据至中间库
* @param dingFailure
*/
@Async("asyncServiceExecutor")
@Transactional(rollbackFor = Exception.class)
public void disposeDingCheck(String dingFailure) {
JSONObject bizData = JSONObject.parseObject(dingFailure);
//获取eventId
String eventId = bizData.getString("eventId");
try {
//处理考勤打卡
JSONArray dataList = bizData.getJSONArray("dataList");
//打卡数据
JSONObject dataItem = new JSONObject();
//打卡地点
String address = null;
//打卡类型
String locationMethod = null;
if (ObjectUtil.isNotEmpty(dataList)) {
dataItem = dataList.getJSONObject(0);
address = dataItem.getString("address");
locationMethod = dataItem.getString("locationMethod");
}
LocalDateTime now = LocalDateTime.now();
//根据打卡类型封装打卡信息
if (ObjectUtil.isNotEmpty(locationMethod)) {
//钉钉打卡数据更换表 2024-05-28
VvAtdAttrecordDingding vvAtdAttrecordDingding = new VvAtdAttrecordDingding();
//打卡人员
String userId = dataItem.getString("userId");
User userByDingNumber = iUserClient.getUserByDingNumber(userId).getData();
String realName = null;
String groupCode = null;
if (ObjectUtil.isNotEmpty(userByDingNumber)){
realName = userByDingNumber.getRealName();
groupCode = userByDingNumber.getGroupCode();
}else {
DingUserInfo userInfo = iDingTalkUserClient.getUserByDingCode(userId).getData();
if (ObjectUtil.isNotEmpty(userInfo)) {
//姓名
realName = userInfo.getDingName();
//工号
groupCode = userInfo.getDingNumber();
//更新用户信息
UpdateUserDingNumberDTO updateUserDingNumberDTO = new UpdateUserDingNumberDTO();
updateUserDingNumberDTO.setDingNumber(userId);
updateUserDingNumberDTO.setGroupCode(groupCode);
Boolean updateResult = iUserClient.updateUserByGroupCode(updateUserDingNumberDTO).getData();
}
}
if (ObjectUtil.isNotEmpty(realName)&&ObjectUtil.isNotEmpty(groupCode)) {
//姓名+工号
vvAtdAttrecordDingding.setF_name(realName);
vvAtdAttrecordDingding.setEmpcode(groupCode);
//打卡时间
String checkTime = dataItem.getString("checkTime");
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
//打卡地点
String flag = "钉钉打卡:";
//wifi 默认为国内 MAP&ATM 国内外都有
if (locationMethod.equals("WIFI")) {
String ssid = dataItem.getString("ssid");
//打卡地点/考勤机
vvAtdAttrecordDingding.set_Dkdd(flag.concat(ssid));
//打卡时间
vvAtdAttrecordDingding.setAtttime(LocalDateTime.parse(timeConversion(checkTime, "beijing"), formatter));
} else {
if (ObjectUtil.isNotEmpty(address)) {
//打卡地点/考勤机
vvAtdAttrecordDingding.set_Dkdd(flag.concat(address));
//打卡时间
if (address.contains("VN")) {
vvAtdAttrecordDingding.setAtttime(LocalDateTime.parse(timeConversion(checkTime, "vietnam"), formatter));
} else if (address.contains("India")) {
vvAtdAttrecordDingding.setAtttime(LocalDateTime.parse(timeConversion(checkTime, "india"), formatter));
} else {
vvAtdAttrecordDingding.setAtttime(LocalDateTime.parse(timeConversion(checkTime, "beijing"), formatter));
}
}
}
//打卡方式
vvAtdAttrecordDingding.set_sjyt("钉钉打卡");
//同步时间
vvAtdAttrecordDingding.setSyncTime(now);
vvAtdAttrecordDingding.setCreatorinfo("钉钉事件与回调" + now);
boolean save = vvAtdAttrecordDingdingService.save(vvAtdAttrecordDingding);
if (save) {
String msg = "已处理";
this.baseMapper.updateLog(msg, eventId);
}
} else {
//人员找不到的异常情况
String msg = "人员未找到(根据ding_ding_num)";
this.baseMapper.updateLog(msg, eventId);
}
} else {
//打卡类型为空,异常记录
String msg = "打卡类型字段为空";
this.baseMapper.updateLog(msg, eventId);
}
} catch (Exception e) {
//异常处理
this.baseMapper.updateLog(e.getMessage(), eventId);
}
}
}
4、上一步中有关人员和钉钉id的对应关系,可以直接在钉钉的开发文档中搜索即可。这边有个建议是最好在自己的人员表中添加一个字段为钉钉的id信息,这样在每次打卡数据处理的时候优先查询自己这边数据库中的人员信息,而不是调用钉钉的接口。一是避免访问钉钉接口的不稳定(外网访问)以及钉钉的接口调用额度,二是调用速度。
5、关于失败处理补偿机制,也记录一下。
/**
* 获取钉钉员工打卡事件推送失败数据
* @return
*/
@Override
public R<List<DingCheckInfo>> getFailedDingCheck() {
List<DingCheckInfo> dingCheckInfoList = new ArrayList<>();
//获取钉钉考勤打卡的token
final String accessToken = dingTalkUtil.getTokenByMicroAppType(MicroAppTypeEnum.DING_CHECK);
MultiValueMap<String, String> multiValueMap = new LinkedMultiValueMap<>();
multiValueMap.add("access_token",accessToken);
//调用获取推送失败数据 目前钉钉对接-重要的微应用 只监听员工打卡事件
DingEventResponse dingEventResponse = interfacePlatformRequestService.get(DingEventResponse.class
, "/get_call_back_failed_result"
,multiValueMap);
//获取推送失败数据
List<DingFailedListDTO> failedList = dingEventResponse.getFailed_list();
if (ObjectUtil.isNotEmpty(failedList)){
for (DingFailedListDTO dingFailedListDTO : failedList) {
//虽然目前只监听打卡事件,但防止后面该应用添加监听事件,还是加一个事件判断吧
String call_back_tag = dingFailedListDTO.getCall_back_tag();
if (!call_back_tag.equals("attendance_check_record")){
continue;
}
DingCallbackDataDTO callbackData = dingFailedListDTO.getAttendance_check_record().getCallbackData();
DingCheckInfo dingCheckInfo = new DingCheckInfo();
dingCheckInfo.setEventId(callbackData.getEventId());
dingCheckInfo.setDataList(callbackData.getDataList());
dingCheckInfoList.add(dingCheckInfo);
}
}
return R.data(dingCheckInfoList);
}
其中的获取token和获取失败记录的方法也简答记录一下。
/**
* 通过微应用类型获取对应的钉钉token
*
* @param appTypeEnum
* @return
* @throws ApiException
*/
public String getTokenByMicroAppType(MicroAppTypeEnum appTypeEnum) {
/**
* 如果redis里获取到了,直接返回
*/
if (ObjectUtil.isNotEmpty(redisUtils.get(DingTalkApiPathConstants.DING_TALK_TOKEN_PREFIX + appTypeEnum.getCode()))) {
return redisUtils.get(DingTalkApiPathConstants.DING_TALK_TOKEN_PREFIX + appTypeEnum.getCode());
}
// 如果redis里没有获取到了,获取token,存入redis,然后返回
DingTalkClient client = new DefaultDingTalkClient(DingTalkApiPathConstants.GET_TOKEN);
OapiGettokenRequest request = new OapiGettokenRequest();
request.setAppkey(appTypeEnum.getAppKey());
request.setAppsecret(appTypeEnum.getAppSecret());
request.setHttpMethod("GET");
OapiGettokenResponse response = null;
try {
response = client.execute(request);
} catch (Exception e) {
log.error(e.getMessage());
throw new DingTalkException(String.format("获取应用[%s]的token失败", appTypeEnum.getCode()));
}
if (!response.isSuccess() || StringUtil.isBlank(response.getAccessToken())) {
log.error(String.format("获取应用[%s]的token失败", appTypeEnum.getCode()));
throw new DingTalkException(String.format("获取应用[%s]的token失败", appTypeEnum.getCode()));
}
// 钉钉token的有效期为7200秒(2小时),有效期内重复获取会返回相同结果并自动续期,过期后获取会返回新的access_token
final String accessToken = response.getAccessToken();
Long expiresIn = response.getExpiresIn();
// 如果有效期大于7200,则减少200秒,防止调接口时刚好过期
if (expiresIn != null && expiresIn >= 7200) {
expiresIn -= 200;
}
// 将token存入redis中
redisUtils.setEx(DingTalkApiPathConstants.DING_TALK_TOKEN_PREFIX + appTypeEnum.getCode(), accessToken, expiresIn);
return accessToken;
@Value("${restCloud.prefix}")
private String prefix;
public <T> T get(Class<T> clazz, String url, MultiValueMap<String, String> multiValueMap){
// 使用UriComponentsBuilder构建带有查询参数的URL
String fullUrl = UriComponentsBuilder.fromHttpUrl(prefix.concat("/get_call_back_failed_result"))
.queryParams(multiValueMap)
.build()
.toUriString();
return (T)restTemplate.getForObject(fullUrl,clazz);
}
至于其中的一些接口地址,你们直接在钉钉文档查看就好了,我的没有参考价值,因为我们的接口是在我们的接口平台进行了封装。
/**
* 获取钉钉员工打卡事件推送失败数据
* @return
*/
@Override
public R<List<DingCheckInfo>> getFailedDingCheck() {
List<DingCheckInfo> dingCheckInfoList = new ArrayList<>();
//获取钉钉考勤打卡的token
final String accessToken = dingTalkUtil.getTokenByMicroAppType(MicroAppTypeEnum.DING_CHECK);
MultiValueMap<String, String> multiValueMap = new LinkedMultiValueMap<>();
multiValueMap.add("access_token",accessToken);
//调用获取推送失败数据 目前钉钉对接-重要的微应用 只监听员工打卡事件
DingEventResponse dingEventResponse = interfacePlatformRequestService.get(DingEventResponse.class
, "/get_call_back_failed_result"
,multiValueMap);
//获取推送失败数据
List<DingFailedListDTO> failedList = dingEventResponse.getFailed_list();
if (ObjectUtil.isNotEmpty(failedList)){
for (DingFailedListDTO dingFailedListDTO : failedList) {
//虽然目前只监听打卡事件,但防止后面该应用添加监听事件,还是加一个事件判断吧
String call_back_tag = dingFailedListDTO.getCall_back_tag();
if (!call_back_tag.equals("attendance_check_record")){
continue;
}
DingCallbackDataDTO callbackData = dingFailedListDTO.getAttendance_check_record().getCallbackData();
DingCheckInfo dingCheckInfo = new DingCheckInfo();
dingCheckInfo.setEventId(callbackData.getEventId());
dingCheckInfo.setDataList(callbackData.getDataList());
dingCheckInfoList.add(dingCheckInfo);
}
}
return R.data(dingCheckInfoList);
}