前言
近期开发一个用golang同步企业微信的打卡数据到HR系统中间表的应用,开发的过程中遇到了一些问题,在这里把源码及遇到的问题及解决方法分亨出来供大家参考一下。
一、了解企业微信的打卡数据接口文档?
企微的钉钉打卡数据接口文档大家可以到企微的开放平台查看
接口文档很简单:
{
"opencheckindatatype": 3,
"starttime": 1492617600,
"endtime": 1492790400,
"useridlist": ["james","paul"]
}
1. 获取记录时间跨度不超过30天 2. 用户列表不超过100个。若用户超过100个,请分批获取 3. 有打卡记录即可获取打卡数据,与当前"打卡应用"是否开启无关 4. 标准打卡时间只对于固定排班和自定义排班两种类型有效 5. 接口调用频率限制为600次/分钟
话不多説,直接分亨源码
二、获取企业微信打卡记录源码
1.接口源码
package clockin
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"github.com/go-redis/redis/v8"
"github.com/spf13/viper"
"go.uber.org/ratelimit"
"gorm.io/driver/sqlserver"
"gorm.io/gorm"
"io/ioutil"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"wdmtool/util"
)
type QyoaToken struct {
Errcode int `json:"errcode"`
AccessToken string `json:"access_token"`
Errmsg string `json:"errmsg"`
ExpiresIn int64 `json:"expires_in"`
ExpiresTime int64 `json:"expires_time"`
}
type ListIdReq struct {
Cursor string `json:"cursor"`
Limit uint64 `json:"limit"`
}
type UserIdRec struct {
Userid string `json:"userid"`
Department int `json:"department"`
}
type ListIdResult struct {
Errcode int `json:"errcode"`
Errmsg string `json:"errmsg"`
NextCursor string `json:"next_cursor"`
DeptUser []UserIdRec `json:"dept_user"`
}
type CheckinDataReq struct {
OpenCheckInDataType int `json:"opencheckindatatype"`
StartTime int64 `json:"starttime"`
EndTime int64 `json:"endtime"`
UserIdList []string `json:"useridlist"`
}
type CheckinDataResult struct {
Errcode int `json:"errcode"`
Errmsg string `json:"errmsg"`
CheckinData []CheckinData `json:"checkindata"`
}
type CheckinData struct {
Userid string `json:"userid"`
Groupname string `json:"groupname"`
CheckinType string `json:"checkin_type"`
ExceptionType string `json:"exception_type"`
CheckinTime int64 `json:"checkin_time"`
LocationTitle string `json:"location_title"`
LocationDetail string `json:"location_detail"`
Wifiname string `json:"wifiname"`
Wifimac string `json:"wifimac"`
Mediaids []string `json:"mediaids"`
DeviceId string `json:"deviceid"`
SchCheckinTime int64 `json:"sch_checkin_time"`
Groupid int `json:"groupid"`
ScheduleId int `json:"schedule_id"`
TimelineId int `json:"timeline_id"`
}
type WdmCheckinData struct {
CheckType string `gorm:"column:checkType;type:varchar(50);comment:'考勤类型 OnDuty:上班 OffDuty:下班'"`
DDid string `gorm:"column:DDid;type:varchar(50);comment:'钉钉ID'"`
LocationResult string `gorm:"column:locationResult;type:varchar(50);comment:'位置结果:Normal:范围内Outside:范围外NotSigned:未打卡'"`
RecordId string `gorm:"column:recordId;type:varchar(50);comment:'打卡记录ID'"`
UserCheckTime string `gorm:"column:userCheckTime;type:varchar(50);comment:'实际打卡时间'"`
UserId string `gorm:"column:userId;type:varchar(50);comment:'打卡人的UserID'"`
WorkDate string `gorm:"column:workDate;type:varchar(50);comment:'工作日'"`
GmtModified string `gorm:"column:gmtModified;type:varchar(50);comment:'打卡记录修改时间'"`
IsLegal string `gorm:"column:isLegal;type:varchar(50);comment:'Y:合法 N:不合法'"` //是否合法
BaseCheckTime string `gorm:"column:baseCheckTime;type:varchar(50);comment:'计算迟到和早退,基准时间;也可作为排班打卡时间'"`
UserAddress string `gorm:"column:userAddress;type:varchar(2000);comment:'用户打卡地址 如果是考勤机打卡 userAddress 返回的是考勤机名称'"`
TimeResult string `gorm:"column:timeResult;type:varchar(50);comment:'打卡结果:Normal:正常Early:早退Late:迟到SeriousLate严重迟到Absenteeism:旷工迟到 NotSigned:未打卡'"`
DeviceId string `gorm:"column:deviceId;type:varchar(50);comment:'deviceId'"` //打卡设备ID
//ATM:考勤机打卡(指纹/人脸打卡)
//BEACON:IBeacon
//DING_ATM:钉钉考勤机(考勤机蓝牙打卡)
//USER:用户打卡
//BOSS:老板改签
//APPROVE:审批系统
//SYSTEM:考勤系统
//AUTO_CHECK:自动打卡
SourceType string `gorm:"column:sourceType;type:varchar(50);comment:'数据来源'"`
PlanCheckTime string `gorm:"column:planCheckTime;type:varchar(50);comment:'排班打卡时间'"`
GmtCreate string `gorm:"column:gmtCreate;type:varchar(50);comment:'打卡记录创建时间'"`
LocationMethod string `gorm:"column:locationMethod;type:varchar(50);comment:'MAP'"` //定位方法
PlanId string `gorm:"column:planId;type:varchar(50);comment:'排班ID'"`
GroupId string `gorm:"column:groupId;type:varchar(50);comment:'考勤组ID'"`
InsertTime string `gorm:"column:InsertTime;type:datetime;comment:'InsertTime'"`
}
var ctx = context.Background()
var DB *gorm.DB
var rdb *redis.Client
func Sync() {
InitHrDB()
tokenTxl, err := GetToken(viper.GetString("QyoaWork.WdmTXLSecret"))
if err != nil {
log.Println("获取企业微信接口token返回错误", err.Error())
return
}
log.Println("获取企业微信通讯录接口token:", tokenTxl)
allUserIds, err := getUserIdDepId(tokenTxl)
//log.Println(allUserIds)
if err != nil {
log.Println("获取企业微信人员返回错误", err.Error())
return
}
if len(allUserIds) == 0 {
log.Println("获取企业微信人员返回人数为空无需处理")
return
}
tokenKaoqin, err := GetToken(viper.GetString("QyoaWork.WdmAttendanceSecret"))
if err != nil {
log.Println("获取企业微信接口获取考勤token返回错误", err.Error())
return
}
if util.Fday > 0 {
util.Fday = -1
}
log.Println("获取企业微信考蓝带接口token:", tokenKaoqin)
timeObj := time.Now()
var stime time.Time
var etime time.Time
hasTime := 0
if util.Fday < 0 {
// 获取开始日期
oldTime := timeObj.AddDate(0, 0, util.Fday)
oldDate := oldTime.Format("2006-01-02")
log.Println(oldDate)
stime, _ = time.ParseInLocation("2006-01-02 15:04:05", oldDate+" 00:00:00", time.Local)
// 获取结束日期
newTime := timeObj.AddDate(0, 0, -1)
endDate := newTime.Format("2006-01-02")
log.Println(endDate)
etime, _ = time.ParseInLocation("2006-01-02 15:04:05", endDate+" 23:59:59", time.Local)
etime.Add(time.Second * time.Duration(1))
hasTime = 1
}
if util.Fsday != "" && util.Feday != "" {
stime, _ = time.ParseInLocation("2006-01-02 15:04:05", util.Fsday+" 00:00:00", time.Local)
etime, _ = time.ParseInLocation("2006-01-02 15:04:05", util.Feday+" 23:59:59", time.Local)
etime.Add(time.Second * time.Duration(1))
hasTime = 1
}
if hasTime == 0 {
log.Println("打卡开始日期和结束日期错误")
return
}
wg := sync.WaitGroup{}
workers := 5
in := make(chan struct{}, workers)
rl := ratelimit.New(10)
defer close(in)
i := 0
for _, User := range allUserIds {
in <- struct{}{}
wg.Add(1)
rl.Take()
go Getcheckindata(User, tokenKaoqin, stime.Unix(), etime.Unix(), &wg, in)
i++
}
wg.Wait()
fmt.Println("sync")
}
func Getcheckindata(user UserIdRec, token string, Stime int64, Etime int64, wg *sync.WaitGroup, out chan struct{}) error {
defer func() {
wg.Done()
<-out
}()
log.Println("Getcheckindata")
//获取打卡记录
var Url string = "https://qyapi.weixin.qq.com/cgi-bin/checkin/getcheckindata?access_token=" + token
log.Println("获取企业微信人员打卡数据URL:", Url)
var param CheckinDataReq
param.OpenCheckInDataType = 3
param.StartTime = Stime
param.EndTime = Etime
param.UserIdList = []string{user.Userid}
paramStr, _ := json.Marshal(param)
log.Println("获取企业微信人员打卡数据请求参数:", string(paramStr))
req, err := http.NewRequest("POST", Url, bytes.NewBuffer(paramStr))
if err != nil {
log.Println(err.Error())
return err
}
log.Println("11111")
client := &http.Client{}
resp, err := client.Do(req)
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Println(err.Error())
return err
}
log.Println(string(body))
log.Println("222222")
var Result CheckinDataResult
err = json.Unmarshal(body, &Result)
log.Println(err)
if err != nil {
return err
}
log.Println("获取企业微信人员打卡结果:", Result)
log.Println("333333333")
if Result.Errcode == 0 && Result.Errmsg == "ok" {
var wdmrec WdmCheckinData
checkTimeStr := ""
schCheckTimeStr := ""
locationResult := ""
timeResult := ""
for _, rec := range Result.CheckinData {
log.Println(rec)
switch rec.CheckinType {
case "上班打卡":
wdmrec.CheckType = "OnDuty"
case "下班打卡":
wdmrec.CheckType = "OffDuty"
default:
wdmrec.CheckType = rec.CheckinType
}
//时间异常,地点异常,未打卡,wifi异常,非常用设备如果有多个异常,以分号间隔
locationResult = "Normal"
timeResult = "Normal"
if rec.ExceptionType != "" {
if strings.Contains(rec.ExceptionType, "地点异常") {
locationResult = "Outside"
}
if strings.Contains(rec.ExceptionType, "时间异常") {
timeResult = ""
}
if strings.Contains(rec.ExceptionType, "未打卡") {
timeResult = "NotSigned"
}
if strings.Contains(rec.ExceptionType, "wifi异常") {
timeResult = "Outside"
}
if strings.Contains(rec.ExceptionType, "非常用设备") {
locationResult = rec.ExceptionType
}
}
checkTimeStr = time.Unix(rec.CheckinTime, 0).Format("2006/01/02 15:04:05")
if rec.SchCheckinTime > 0 {
schCheckTimeStr = time.Unix(rec.SchCheckinTime, 0).Format("2006/01/02") + " 00:00:00"
}
wdmrec.DDid = rec.Userid
wdmrec.LocationResult = locationResult
wdmrec.RecordId = ""
wdmrec.UserCheckTime = checkTimeStr
wdmrec.UserId = rec.Userid
wdmrec.WorkDate = schCheckTimeStr
wdmrec.GmtModified = checkTimeStr
if locationResult == "Normal" && timeResult == "Normal" {
wdmrec.IsLegal = "Y"
} else {
wdmrec.IsLegal = "N"
}
wdmrec.BaseCheckTime = time.Unix(rec.CheckinTime, 0).Format("2006/01/02 15:04") + ":00"
wdmrec.UserAddress = rec.LocationTitle
wdmrec.TimeResult = timeResult
wdmrec.DeviceId = rec.DeviceId
wdmrec.SourceType = "USER"
wdmrec.PlanCheckTime = checkTimeStr
wdmrec.GmtCreate = checkTimeStr
wdmrec.LocationMethod = "MAP"
wdmrec.PlanId = strconv.Itoa(rec.ScheduleId)
wdmrec.GroupId = strconv.Itoa(rec.Groupid)
wdmrec.InsertTime = time.Now().Format("2006-01-02 15:04:05.999")
DB.Table("LsyncQYWXforK_Cardwdm_back").Debug().Create(&wdmrec)
}
} else {
return errors.New(strconv.Itoa(Result.Errcode) + Result.Errmsg)
}
return nil
}
func getUserIdDepId(token string) (UserList []UserIdRec, err error) {
var Url string = "https://qyapi.weixin.qq.com/cgi-bin/user/list_id?access_token=" + token
//log.Println("获取企业微信人员打卡数据URL:", Url)
var param ListIdReq
//param["access_token"] = []string{token}
param.Limit = 2000
client := &http.Client{}
for {
paramStr, _ := json.Marshal(param)
req, err := http.NewRequest("POST", Url, bytes.NewBuffer(paramStr))
if err != nil {
log.Println(err.Error())
return UserList, err
}
resp, err := client.Do(req)
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Println(err.Error())
resp.Body.Close()
return UserList, err
}
var Result ListIdResult
err = json.Unmarshal(body, &Result)
if err != nil {
return UserList, err
}
if Result.Errcode == 0 && Result.Errmsg == "ok" {
UserList = append(UserList, Result.DeptUser...)
} else {
resp.Body.Close()
return UserList, errors.New(strconv.Itoa(Result.Errcode) + Result.Errmsg)
}
if len(Result.DeptUser) == 0 || Result.NextCursor == "" {
break
}
param.Cursor = Result.NextCursor
resp.Body.Close()
}
return UserList, nil
}
func GetToken(secret string) (token string, err error) {
corpid := viper.GetString("QyoaWork.CorpID")
appSecrect := secret
tokenredishost := viper.GetString("QyoaWork.TokenRedisHost")
tokenredisport := viper.GetString("QyoaWork.TokenRedisPort")
rdb := redis.NewClient(&redis.Options{
Addr: tokenredishost + ":" + tokenredisport,
Password: "", // no password set
DB: 0, // use default DB
})
defer func() {
rdb.Close()
}()
key := "qyoa_access_token_" + corpid + "_" + appSecrect
var Mytoken QyoaToken
log.Println(key)
strToken, err := rdb.Get(ctx, key).Result()
log.Println(strToken)
if err == nil {
err = json.Unmarshal([]byte(strToken), &Mytoken)
if err != nil {
return "", err
}
if Mytoken.Errcode == 0 && Mytoken.ExpiresTime > time.Now().Unix() {
log.Println("从redis里获取了token")
return Mytoken.AccessToken, nil
}
}
url := "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=" + corpid + "&corpsecret=" + appSecrect
resp, err := http.Get(url)
log.Println("gettoken resp", resp)
if err != nil {
return "", err
}
defer resp.Body.Close()
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", err
}
err = json.Unmarshal(b, &Mytoken)
if err != nil {
return "", err
}
if Mytoken.Errcode == 0 && Mytoken.Errmsg == "ok" {
rdb.SetEX(ctx, key, string(b), time.Duration(Mytoken.ExpiresIn-300)*time.Second)
return Mytoken.AccessToken, nil
}
return "", errors.New(strconv.Itoa(Mytoken.Errcode) + Mytoken.Errmsg)
}
func InitHrDB() *gorm.DB {
driver := viper.GetString("HrDatabase.Driver")
host := viper.GetString("HrDatabase.Host")
port := viper.GetString("HrDatabase.Port")
database := viper.GetString("HrDatabase.Database")
username := viper.GetString("HrDatabase.Username")
password := viper.GetString("HrDatabase.Password")
query := url.Values{}
query.Add("database", database)
query.Add("encrypt", "disable")
dsn := &url.URL{
Scheme: driver,
User: url.UserPassword(username, password),
Host: host + ":" + port,
RawQuery: query.Encode(),
}
fmt.Println(dsn.String())
db, err := gorm.Open(sqlserver.Open(dsn.String()), &gorm.Config{})
if err != nil {
panic("failed to connect database, err" + err.Error())
}
DB = db
return db
}
三、源码解析
源码中涉及到获取接口token的部分,获取到token后存放到redis
func GetToken(secret string) (token string, err error) {
corpid := viper.GetString("QyoaWork.CorpID")
appSecrect := secret
tokenredishost := viper.GetString("QyoaWork.TokenRedisHost")
tokenredisport := viper.GetString("QyoaWork.TokenRedisPort")
rdb := redis.NewClient(&redis.Options{
Addr: tokenredishost + ":" + tokenredisport,
Password: "", // no password set
DB: 0, // use default DB
})
defer func() {
rdb.Close()
}()
key := "qyoa_access_token_" + corpid + "_" + appSecrect
var Mytoken QyoaToken
log.Println(key)
strToken, err := rdb.Get(ctx, key).Result()
log.Println(strToken)
if err == nil {
err = json.Unmarshal([]byte(strToken), &Mytoken)
if err != nil {
return "", err
}
if Mytoken.Errcode == 0 && Mytoken.ExpiresTime > time.Now().Unix() {
log.Println("从redis里获取了token")
return Mytoken.AccessToken, nil
}
}
url := "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=" + corpid + "&corpsecret=" + appSecrect
resp, err := http.Get(url)
log.Println("gettoken resp", resp)
if err != nil {
return "", err
}
defer resp.Body.Close()
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", err
}
err = json.Unmarshal(b, &Mytoken)
if err != nil {
return "", err
}
if Mytoken.Errcode == 0 && Mytoken.Errmsg == "ok" {
rdb.SetEX(ctx, key, string(b), time.Duration(Mytoken.ExpiresIn-300)*time.Second)
return Mytoken.AccessToken, nil
}
return "", errors.New(strconv.Itoa(Mytoken.Errcode) + Mytoken.Errmsg)
}
获取token里可能汲到应用接口的token及企微通讯录的token,两种token是不一样的,所以封装成了一个函数
另外企微对获取打卡数据限频了,我们用ratelimit做了请求接口的限频的处理:
wg := sync.WaitGroup{}
workers := 5
in := make(chan struct{}, workers)
rl := ratelimit.New(10)
defer close(in)
i := 0
for _, User := range allUserIds {
in <- struct{}{}
wg.Add(1)
rl.Take()
go Getcheckindata(User, tokenKaoqin, stime.Unix(), etime.Unix(), &wg, in)
i++
}
wg.Wait()
fmt.Println("sync")
同时在入库时,遇到了
[SQL Server]如果 DML 语句包含不带 INTO 子句的 OUTPUT 子句,则该语句的目标表 'LsyncQYWXforK_Cardwdm_back' 不能具有任何启用的触发器。
这个错误主要是golang在入库里生成的sql语句会加上
OUTPUT INSERTED."id"
mssqlserver里在执行时就报错了,解决法办是在定义WdmCheckinData这个对象时不定义主键,就不会自动生成
总结
这两天太忙了,讲得不是很详细,有问题的大家可以留言沟通。