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 } }