企业微信第三方服务商应用开发

从之前的博客搬运过来的

Posted by Loriame on 2020-07-08
Estimated Reading Time 13 Minutes
Words 3.4k In Total
Viewed Times

最近公司要做 OA 平台,要搞企业微信的第三方应用,现在正在踩坑中

更新日期:2020/07/22

已经踩了一部分坑,现在整理一下流程

企业微信第三方应用是什么?

打开企业微信工作台,你会看到很多应用,里面有企业微信官方的应用,有企业内部开发的应用,也有第三方服务商应用,从使用上讲,感觉不到他们有什么区别

企业微信内部应用,顾名思义,是管理企业自身业务的应用,它需要在企业微信后台中手动添加,只能在自己的企业内使用,企业自身就是客户

企业微信第三方应用,类似微信小程序,它可以上架到企业微信应用市场,在工作台中点击“添加应用”即可在市场中选择某个第三方应用并添加到应用列表,对服务商企业而言,客户不只有自身,其开发的应用可以被其他企业安装使用

开发过程

因为服务商应用调试只能在线上进行,所以我们只好注册两个企业,下文中称作服务商企业客户企业

创建应用

使用服务商企业登陆服务商管理后台,可以查看当前服务商应用列表

创建网页应用

点击创建应用,填入必要的信息(应用名称、Logo 等)

如果没有特殊需要,不要选择成员敏感信息,因为当前企业微信好像不支持上架获取成员敏感信息的应用,如果有需要建议提前联系官方客服确认

重点是下一步,配置开发信息

配置开发信息

  • 应用主页: 用户在企业微信工作台中点击应用图标后,会跳转到这里配置的页面
  • 可信域名:只能在可信域名下的页面使用 Oauth 登录授权链接或 JS-SDK 的功能
  • 安装完成回调域名: 从服务商网站发起授权应用(非企业微信应用市场安装),会302 跳转到该域名下的 Url,发生跳转时企业微信会检查页面是否在此处填写的域名下,以杜绝伪造攻击
  • 业务设置 Url:服务商企业管理员可以从企业微信后台应用详情页免登陆跳转到该链接(笔者暂未用到,暂时是填了主页)
  • 数据回调 Url:用于接收用户发送给应用的消息和事件,包括文本、图片、视频、位置、链接等消息和成员关注、取消关注、进入应用、通讯录变更等事件,详见消息推送
  • 指令回调 Url:用于接收 suite_ticket(用于获取接口调用凭证)和授权(安装应用、卸载应用等)、成员、部门、标签等通知事件,详见指令回调接口
  • Token 和 EncodingAESKey: 这两项都是和解密相关的参数,随机生成即可,不需要频繁改动,在代码中解密企业微信的加密消息时需要使用这两项的值

其他参数

  • CorpID: 服务商的 id,可以在应用管理-通用开发参数中查询到
  • SuiteID: 第三方应用 id,在应用详情中查看
  • Secret: 第三方应用的秘钥,在应用详情中查看

回调 Url

企业微信官方文档的回调配置看完之后我还是很懵,并没有很清晰的知道接口具体如何实现,全靠实践不断调试才有所了解

下面分场景来介绍回调接口的写法(以 python 为例)

Get 和 Post 分场景

企业微信要求我们的回调接口(包括数据回调和指令回调)都要同时实现 get 和 post 请求,get 请求用于验证 url 有效性,post 请求用于接收业务数据

然而实际用到 get 请求的场景只有在创建应用填写回调 url 后保存时,企业微信会立刻发送一个 get 请求到我们的服务器

post 请求文档有规定,除了一些特殊的推送消息以外必须返回字符串"success"

首先我们把数据回调和指令回调的接口根据请求类型分开处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 数据回调
@app.route('/wechatMessage', methods=['POST', 'GET'])
def wechatMessage():
if request.method == 'POST':
# post请求 处理业务
return 'success'
else:
# get请求 处理验证

# 指令回调
@app.route('/wechatEventHandle', methods=['POST', 'GET'])
def wechatEventHandle():
if request.method == 'POST':
# post请求 处理业务
return 'success'
else:
# get请求 处理验证

Get 请求验证 URL 有效性

验证URL有效性

在创建应用填写回调配置完成后,企业微信会立刻发送一个 get 请求到填写的 url 验证是否有效

从官方文档可以了解到,验证请求有效性具体做法是,将传来的echostr参数通过timestampnonce解密出明文,并与msg_signature比较是否一致,如果一致,把解密出的echostr明文返回给企业微信服务器

解密的过程需要使用解密库,本文使用的是 python 库,其他语言版本可以参考加解密库下载与返回码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 数据回调
@app.route('/wechatMessage', methods=['POST', 'GET'])
def wechatMessage():
if request.method == 'POST':
# post请求 处理业务
return 'success'
else:
# get请求 处理验证
sVerifyMsgSig = request.values.get('msg_signature') # 企业微信加密签名
sVerifyTimeStamp = request.values.get('timestamp') # 时间戳
sVerifyNonce = request.values.get('nonce') # 随机数
sVerifyEchoStr = request.values.get('echostr') # 加密的字符串,需要解密得到消息内容明文
wxcpt = WXBizMsgCrypt(sToken, sEncodingAESKey, sCorpID) # 解密库对象
ret,sEchoStr=wxcpt.VerifyURL(sVerifyMsgSig, sVerifyTimeStamp,sVerifyNonce,sVerifyEchoStr) # 解密
if(ret!=0): # ret=0 为解密成功
print("ERR: VerifyURL ret: " + str(ret))
return sEchoStr # 返回明文

# 指令回调 同上

其中sTokensEncodingAESKeysCorpID为创建应用时的配置

此时我们再次保存回调配置,数据回调和指令回调都可以验证成功

Post 请求接收业务数据

post 请求和 get 请求相比,同样具有msg_signaturetimestampnonce参数,没有echostr参数

当时我一直以为加密内容会从echostr中传过来,一脸疑惑,后来发现加密内容居然是请求体本体

除了以上三个参数外,我们还要获取请求体

1
2
3
4
sReqMsgSig=request.values.get('msg_signature')  # 企业微信加密签名
sReqTimeStamp=request.values.get('timestamp') # 时间戳
sReqNonce = request.values.get('nonce') # 随机数
sReqData = request.get_data() # 加密的xml内容

加密的 xml 内容如以下格式

1
2
3
4
5
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<AgentID><![CDATA[toAgentID]]></AgentID>
<Encrypt><![CDATA[msg_encrypt]]></Encrypt>
</xml>

我们可以直接把整个加密内容丢给解密库,使用它的解密函数获得明文

1
2
3
wxcpt = WXBizMsgCrypt(sToken, sEncodingAESKey, SuiteID)
ret, xml = wxcpt.DecryptMsg(sReqData, sReqMsgSig, sReqTimeStamp, sReqNonce) # 解密
print(xml)

注意此时构造解密库对象时使用的是SuiteID而不是CorpID

不过实际上我亲自试过发现指令回调中使用SuiteID可以正常解析,数据回调会有一些问题

因为数据回调中不是所有的消息都来自于服务商企业的企业微信,有许多是来自客户企业的企业微信,此时SuiteID始终是第三方应用的 id,但收到用户发来的消息和更变可见范围时,密文中的ToUserName为客户企业的企业微信 id,解密库源码中会使用ToUserID明文和你传入的SuiteID做比较,如果不一致则解密失败

此时我的做法是直接将源码中验证一致的代码注释掉,可以正常解析所有明文

我的理解是,企业微信加密库原本是用于企业微信内部应用开发,会判断消息来源也很正常,现在要用于第三方应用开发,没有做好兼容,所以自己动手去掉了验证,如果之后发现有理解错误会及时订正此处

解析出的明文仍然是一个 xml

数据回调消息类型

数据回调的解密后 xml 格式如下:

1
2
3
4
5
6
7
8
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[UserID]]></FromUserName>
<CreateTime>1348831860</CreateTime>
<MsgType><![CDATA[event]]></MsgType>
<Event><![CDATA[subscribe]]></Event>
<AgentID>1</AgentID>
</xml>

以成员关注及取消关注事件为例,不同事件字段有可能不一样,但都有MsgType消息类型,可以通过这个字段来对不同事件分开处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
xml_tree = ET.fromstring(xml)   # 解析解密后的xml文本
MsgType = xml_tree.find("MsgType").text # 密文中的事件类型
if MsgType == 'event':
# 处理event事件
Event = xml_tree.find("Event").text # 密文中的事件类型
if Event == 'subscribe':
# 关注
elif Event == 'unsubscribe':
# 取消关注
elif MsgType == 'text':
# 处理收到文本消息事件
elif MsgType == 'image':
# 处理收到图片消息事件

...

具体的数据回调消息格式可以参考消息格式

指令回调消息类型

指令回调的解密后 xml 格式如下:

1
2
3
4
5
6
<xml>
<SuiteId><![CDATA[ww4asffe99e54c0fxxxx]]></SuiteId>
<InfoType> <![CDATA[suite_ticket]]></InfoType>
<TimeStamp>1403610513</TimeStamp>
<SuiteTicket><![CDATA[asdfasfdasdfasdf]]></SuiteTicket>
</xml>

以推送 suite_ticket 事件为例,类似数据回调,指令回调的事件类型使用InfoType判断

1
2
3
4
5
6
7
8
9
10
xml_tree = ET.fromstring(xml)   # 解析解密后的xml文本
InfoType = xml_tree.find("InfoType").text # 密文中的事件类型
if InfoType == 'suite_ticket':
# 处理推送suite_ticket事件
elif InfoType == 'create_auth':
# 处理授权(安装)事件
elif InfoType == 'cancel_auth':
# 处理取消授权(卸载)事件

...

获取用户信息

若想在第三方应用主页获得用户信息,比如 UserId,则需要 Oauth2 授权

  1. 用户进入第三方应用主页,参数里没有认证信息
  2. 构造企业微信授权链接,用户完成授权
  3. 授权后回调到第三方应用主页,携带认证信息code
  4. 将 code 传入我们(服务商)的服务器,使用事先缓存的suite_access_tokencode请求企业微信服务器换取用户信息,并返回给第三方应用主页
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Vue + TypeScript

const param = new URLSearchParams(location.search); // 获取查询参数
if (param.get("code")) {
// 是否包含认证信息
this.code = param.get("code");
// 将code传入服务商后台换取用户信息
axios.get("服务商后台url?code=" + this.code).then((res: any) => {
this.data = res.data; // 用户信息
});
} else {
// 构造企业微信授权链接
const url = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${this.suiteId}&redirect_uri=${this.redirect_uri}&response_type=code&scope=snsapi_base&state=${this.state}#wechat_redirect`;
location.href = url; // 前往授权
}
1
2
3
4
5
6
# 获取用户身份信息
@app.route('/wechatGetUserByCode', methods=['POST', 'GET'])
def wechatGetUserByCode():
code = request.values.get('code')
res = requests.get('https://qyapi.weixin.qq.com/cgi-bin/service/getuserinfo3rd?suite_access_token=' + suite_access_token + '&code=' + code).json()
return res

需要注意的点

  • 构造授权链接时,scope参数可选snsapi_basesnsapi_userinfosnsapi_privateinfo,但snsapi_privateinfo是手动授权(其它的是静默授权),支持获取用户的敏感信息,如手机、邮箱等,现在企业微信不支持上架需要用户敏感信息的服务商应用
  • suite_access_token是在指令回调中,接收推送 suite_ticket 事件,得到suite_ticket,然后使用suite_ticketSuiteIDSecret换取到,并缓存在服务商服务器中的(下文会详细说明)

获取第三方应用凭证

官方开发文档介绍

获取第三方应用凭证suite_access_token,需要准备好应用的SuiteIDSecret,然后调用文档中的获取凭证的企业微信接口

1
2
3
4
5
6
7
8
param = {
"suite_id": SuiteID,
"suite_secret": SuiteSecret,
"suite_ticket": lastSuiteTicket # 收到最新的suite_ticket
}
headers = {'Content-Type': 'application/json'}
res = requests.post('https://qyapi.weixin.qq.com/cgi-bin/service/get_suite_token', headers=headers, data=json.dumps(param)).json()
suite_access_token = res['suite_access_token']

企业微信接口调用时要求参数必须是 json 格式,因此在请求头中添加Content-Type: application/json,并把参数使用 json 库包装一下(其他语言参考,能保证发送 json 数据即可)

如果接口返回以下错误

1
2
3
4
{
'errcode':60020,
'errmsg': 'not allow to access from your ip:client ip xx.xx.xx.xx'
}

说明你的服务商 ip 地址不在服务商管理后台配置的 ip 白名单中,需要登录服务商后台,找到服务商信息选项卡,在 ip 白名单里添加上服务商的 ip 地址

效果如下:

此时我们已经成功做到使用客户企业测试安装服务商企业的应用,并在客户企业打开应用后,通过授权登录,让服务商企业获得了客户企业的 IdCorpId和员工 IdUserId

之后的业务逻辑可以依据CorpId区分企业,UserId绑定用户,具体的实现看公司的需求了

后记

目前我们对企业微信服务商应用的开发也是刚刚起步,如果之后的开发过程中发现了文中没有注意到的点会及时更新此篇

祝大家成功!

神乐mea


如果您喜欢此博客或发现它对您有用,则欢迎对此发表评论。 也欢迎您共享此博客,以便更多人可以参与。 如果博客中使用的图像侵犯了您的版权,请与作者联系以将其删除。 谢谢 !