378 lines
9.8 KiB
TypeScript
378 lines
9.8 KiB
TypeScript
import axios, { AxiosRequestConfig } from 'axios'
|
||
import { singleton } from 'decorators/singleton'
|
||
import fs from 'fs'
|
||
import os from 'os'
|
||
import path from 'path'
|
||
import { RedisClient } from 'redis/RedisClient'
|
||
import { excelToJson } from 'utils/excel.util'
|
||
import { timeBeforeDay } from 'utils/time.util'
|
||
|
||
// 1-审批中;2-已通过;3-已驳回;4-已撤销;6-通过后撤销;7-已删除;10-已支付
|
||
export enum TaskStatus {
|
||
PEDING = 1,
|
||
PASS = 2,
|
||
REJECT = 3,
|
||
CANCEL = 4,
|
||
PASS_CANCEL = 6,
|
||
DELETE = 7,
|
||
PAY = 10,
|
||
}
|
||
|
||
const WX_API_HOST = 'https://qyapi.weixin.qq.com'
|
||
@singleton
|
||
export class WechatWorkService {
|
||
private accessToken: string
|
||
private tokenExpire: number
|
||
private wxToken: string
|
||
private wxAesKey: string
|
||
private wxCorpId: string
|
||
private wxCorpSecret: string
|
||
private timePre: number
|
||
|
||
constructor() {
|
||
this.wxToken = process.env.WX_TOKEN
|
||
this.wxAesKey = process.env.WX_AES_KEY
|
||
this.wxCorpId = process.env.WX_CORP_ID
|
||
this.wxCorpSecret = process.env.WX_CORP_SECRET
|
||
}
|
||
/**
|
||
* 获取access_token
|
||
* @returns access_token
|
||
*/
|
||
public async getAccessToken() {
|
||
if (!this.accessToken || this.tokenExpire < Date.now()) {
|
||
await this.refreshAccessToken()
|
||
}
|
||
return this.accessToken
|
||
}
|
||
/**
|
||
* 获取微信企业号的access_token
|
||
* https://developer.work.weixin.qq.com/resource/devtool
|
||
*/
|
||
public async refreshAccessToken() {
|
||
const url = `${WX_API_HOST}/cgi-bin/gettoken`
|
||
let config: AxiosRequestConfig = {
|
||
method: 'get',
|
||
url,
|
||
params: {
|
||
corpid: this.wxCorpId,
|
||
corpsecret: this.wxCorpSecret,
|
||
},
|
||
}
|
||
let response = await axios.request(config).then(response => {
|
||
return response.data
|
||
})
|
||
if (response.errcode) {
|
||
throw new Error(response.errmsg)
|
||
}
|
||
this.accessToken = response.access_token
|
||
this.tokenExpire = Date.now() + response.expires_in * 1000
|
||
}
|
||
/**
|
||
* 获取审批申请详情
|
||
* https://developer.work.weixin.qq.com/devtool/interface/alone?id=18615
|
||
* @param spNo 审批单号
|
||
*/
|
||
public async fetchApprovalDetail(spNo: string) {
|
||
const url = `${WX_API_HOST}/cgi-bin/oa/getapprovaldetail`
|
||
const access_token = await this.getAccessToken()
|
||
let config: AxiosRequestConfig = {
|
||
method: 'post',
|
||
url,
|
||
params: {
|
||
access_token,
|
||
},
|
||
data: {
|
||
sp_no: spNo,
|
||
},
|
||
}
|
||
let response = await axios.request(config).then(response => {
|
||
return response.data
|
||
})
|
||
return response
|
||
}
|
||
/**
|
||
* 获取用户信息
|
||
* @param userid
|
||
*/
|
||
public async fetchUserInfo(userid: string) {
|
||
const url = `${WX_API_HOST}/cgi-bin/user/get`
|
||
const access_token = await this.getAccessToken()
|
||
let config: AxiosRequestConfig = {
|
||
method: 'get',
|
||
url,
|
||
params: {
|
||
access_token,
|
||
userid,
|
||
},
|
||
}
|
||
let response = await axios.request(config).then(response => {
|
||
return response.data
|
||
})
|
||
if (response.errcode) {
|
||
throw new Error(response.errmsg)
|
||
}
|
||
return response
|
||
}
|
||
|
||
/**
|
||
* 发起一个审核, 用于流程结束时的通知
|
||
* @param userid
|
||
* @returns
|
||
*/
|
||
public async beginApproval({
|
||
userid,
|
||
title,
|
||
desc,
|
||
info,
|
||
}: {
|
||
userid: string
|
||
title: string
|
||
desc: string
|
||
info: string
|
||
}) {
|
||
const url = `${WX_API_HOST}/cgi-bin/oa/applyevent`
|
||
const access_token = await this.getAccessToken()
|
||
let config: AxiosRequestConfig = {
|
||
method: 'post',
|
||
url,
|
||
params: {
|
||
access_token,
|
||
},
|
||
data: {
|
||
creator_userid: userid,
|
||
template_id: process.env.WX_NOTIFY_TEMPLATE_ID,
|
||
use_template_approver: 0,
|
||
approver: [
|
||
{
|
||
userid: userid,
|
||
attr: 1,
|
||
},
|
||
],
|
||
apply_data: {
|
||
contents: [
|
||
{
|
||
control: 'Text',
|
||
id: process.env.WX_NOTIFY_TEXT_ID,
|
||
value: {
|
||
text: title,
|
||
},
|
||
},
|
||
],
|
||
},
|
||
summary_list: [
|
||
{
|
||
summary_info: [
|
||
{
|
||
text: title,
|
||
lang: 'zh_CN',
|
||
},
|
||
],
|
||
},
|
||
{
|
||
summary_info: [
|
||
{
|
||
text: desc,
|
||
lang: 'zh_CN',
|
||
},
|
||
],
|
||
},
|
||
{
|
||
summary_info: [
|
||
{
|
||
text: info,
|
||
lang: 'zh_CN',
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
}
|
||
let response = await axios.request(config).then(response => {
|
||
return response.data
|
||
})
|
||
return response
|
||
}
|
||
/**
|
||
* 调用企业微信审核详情接口, 并处理返回数据
|
||
* @param spNo 审批单号
|
||
*/
|
||
public async parseOneTask(spNo: string) {
|
||
let detail: any = await this.fetchApprovalDetail(spNo)
|
||
if (detail.errcode) {
|
||
throw new Error('approval detail error, code: ' + detail.errcode + ' errmsg: ' + detail.errmsg)
|
||
}
|
||
const { info } = detail
|
||
if (info.sp_status !== TaskStatus.PASS) {
|
||
throw new Error('approval status error, status: ' + info.sp_status)
|
||
}
|
||
const { apply_data, applyer } = info
|
||
const { contents } = apply_data
|
||
let name = ''
|
||
let desc = ''
|
||
let fileId = ''
|
||
let starter = applyer.userid
|
||
for (let content of contents) {
|
||
let { control, value, title } = content
|
||
if (control === 'Text' && title[0].text == '名字') {
|
||
name = value.text
|
||
} else if (control === 'Text' && title[0].text == '描述') {
|
||
desc = value.text
|
||
} else if (control === 'File' && value.files.length > 0) {
|
||
fileId = value.files[0].file_id
|
||
}
|
||
}
|
||
if (fileId) {
|
||
let checked = await this.checkFileMatch(fileId)
|
||
if (!checked) {
|
||
fileId = ''
|
||
}
|
||
}
|
||
if (!fileId) {
|
||
fileId = await this.queryMedidaIdFromSprecord(info.sp_record)
|
||
}
|
||
if (!fileId) {
|
||
fileId = await this.queryMedidaIdFromComment(info.comments)
|
||
}
|
||
if (!fileId) {
|
||
throw new Error('no file')
|
||
}
|
||
let userInfo = await this.fetchUserInfo(starter)
|
||
let starterName = userInfo.name
|
||
let { filename } = await this.fetchFile(fileId)
|
||
let data = excelToJson(filename)
|
||
return { taskId: spNo, name, desc, data, starter, starterName }
|
||
}
|
||
// 检查审核记录中的文件
|
||
private async queryMedidaIdFromSprecord(datas: any) {
|
||
let files = []
|
||
for (let data of datas) {
|
||
if (!data.details || data.details.length === 0) {
|
||
continue
|
||
}
|
||
for (let detail of data.details) {
|
||
if (!detail.media_id || detail.media_id.length === 0) {
|
||
continue
|
||
}
|
||
files = files.concat(detail.media_id)
|
||
}
|
||
}
|
||
let result = ''
|
||
if (files.length > 0) {
|
||
for (let file of files) {
|
||
if (await this.checkFileMatch(file)) {
|
||
result = file
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
// 检查comment中的文件
|
||
private async queryMedidaIdFromComment(datas: any) {
|
||
let files = []
|
||
for (let data of datas) {
|
||
if (!data.media_id || data.media_id.length === 0) {
|
||
continue
|
||
}
|
||
files = files.concat(data.media_id)
|
||
}
|
||
let result = ''
|
||
if (files.length > 0) {
|
||
for (let file of files) {
|
||
if (await this.checkFileMatch(file)) {
|
||
result = file
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
private async checkFileMatch(mediaId: string) {
|
||
let { filename, type } = await this.fetchFile(mediaId, true)
|
||
return filename.indexOf('.xlsx') > 0 && type === 'application/octet-stream'
|
||
}
|
||
|
||
/**
|
||
* 根据media_id获取文件
|
||
* https://developer.work.weixin.qq.com/devtool/interface/alone?id=18615
|
||
* @param mediaId
|
||
*/
|
||
public async fetchFile(mediaId: string, queryOnly = false) {
|
||
const source = axios.CancelToken.source()
|
||
const url = `${WX_API_HOST}/cgi-bin/media/get`
|
||
const access_token = await this.getAccessToken()
|
||
let config: AxiosRequestConfig = {
|
||
method: 'get',
|
||
url,
|
||
responseType: 'arraybuffer',
|
||
cancelToken: source.token,
|
||
params: {
|
||
access_token,
|
||
media_id: mediaId,
|
||
},
|
||
}
|
||
|
||
const res = await axios.request(config)
|
||
if (res.status !== 200) {
|
||
return { filename: '' }
|
||
}
|
||
let regex = /.+?filename="(.+?)"/
|
||
const match = res.headers['content-disposition'].match(regex)
|
||
let remoteName = match ? match[1] : ''
|
||
console.log('filename: ' + remoteName + ' type: ' + res.headers['content-type'])
|
||
if (queryOnly) {
|
||
source.cancel('cancel')
|
||
return { filename: remoteName, type: res.headers['content-type'] }
|
||
}
|
||
let filename = `${mediaId}.xlsx`
|
||
const filePath = path.join(os.tmpdir(), filename)
|
||
fs.writeFileSync(filePath, res.data)
|
||
return { filename: filePath }
|
||
}
|
||
// 查询审批列表
|
||
public async queryTasks() {
|
||
const url = `${WX_API_HOST}/cgi-bin/oa/getapprovalinfo`
|
||
const access_token = await this.getAccessToken()
|
||
let starttime = (timeBeforeDay(7) / 1000) | 0
|
||
// if (!this.timePre) {
|
||
// let timeStr = await new RedisClient().get('qywx_time_cache')
|
||
// if (timeStr) {
|
||
// starttime = parseInt(timeStr)
|
||
// }
|
||
// }
|
||
// starttime = starttime || 1683614900
|
||
let endtime = (Date.now() / 1000) | 0
|
||
let config: AxiosRequestConfig = {
|
||
method: 'post',
|
||
url,
|
||
params: {
|
||
access_token,
|
||
},
|
||
data: {
|
||
starttime,
|
||
endtime,
|
||
cursor: 0,
|
||
size: 100,
|
||
filters: [
|
||
{
|
||
key: 'template_id',
|
||
value: process.env.WX_TEMPLATE_ID,
|
||
},
|
||
{
|
||
key: 'sp_status',
|
||
value: '2',
|
||
},
|
||
],
|
||
},
|
||
}
|
||
let response = await axios.request(config).then(response => {
|
||
return response.data
|
||
})
|
||
this.timePre = endtime
|
||
await new RedisClient().set('qywx_time_cache', endtime + '')
|
||
return response
|
||
}
|
||
}
|