前言

近期开发一个用golang同步企业微信的打卡数据到HR系统中间表的应用,开发的过程中遇到了一些问题,在这里把源码及遇到的问题及解决方法分亨出来供大家参考一下。


一、了解企业微信的打卡数据接口文档?

企微的钉钉打卡数据接口文档大家可以到企微的开放平台查看

java 获取企业微信群聊信息 企业微信获取聊天记录api_golang


接口文档很简单:

java 获取企业微信群聊信息 企业微信获取聊天记录api_golang_02

{
   "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"

java 获取企业微信群聊信息 企业微信获取聊天记录api_json_03

mssqlserver里在执行时就报错了,解决法办是在定义WdmCheckinData这个对象时不定义主键,就不会自动生成

总结

这两天太忙了,讲得不是很详细,有问题的大家可以留言沟通。