企业微信自动化通知系统搭建与排错全纪实

一、 概述与通路选择

系统最初设计目标是通过微信客服(KF)接口将服务器告警透传至个人微信。但在实际推演中,未认证企业号遭遇了腾讯在接口脱敏、权限域隔离方面的层层限制(引发 40096、48002 等典型错误)。

最终方案果断摒弃了复杂的外部客服链路,切换为自建应用消息通道(应用消息)。该通道属于企业微信的基础盘,直接对准企业内部员工的主键账号(UserID)进行投递,具备无权限拦截、配置清晰、高稳定性的优势。


二、 排错、试错与代码演进历程

阶段一:基于微信客服接口的初始搭建

  • 代码目的:搭建最初的 FastAPI 服务,通过微信客服的发送消息接口(kf/send_msg),试图将 URL 传入的 msg 参数内容推送到指定的外部用户 ID。
  • 关键代码(初始版 app.py)
import os
import requests
from fastapi import FastAPI, HTTPException

app = FastAPI()

CORP_ID = os.environ.get("CORP_ID", "")
KF_SECRET = os.environ.get("KF_SECRET", "")
OPEN_KF_ID = os.environ.get("OPEN_KF_ID", "")
TO_USER_ID = os.environ.get("TO_USER_ID", "")

@app.get("/webhook")
def send_wechat_msg(msg: str):
    if not msg:
        raise HTTPException(status_code=400, detail="msg 参数不能为空")
        
    token_url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={CORP_ID}&corpsecret={KF_SECRET}"
    try:
        token_res = requests.get(token_url).json()
        token = token_res.get("access_token")
        if not token:
            return {"status": "error", "message": "获取Token失败", "weixin_error": token_res}
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"请求Token发生异常: {str(e)}")

    send_url = f"https://qyapi.weixin.qq.com/cgi-bin/kf/send_msg?access_token={token}"
    payload = {
        "touser": TO_USER_ID,
        "open_kfid": OPEN_KF_ID,
        "msgtype": "text",
        "text": {
            "content": msg
        }
    }
    
    try:
        send_res = requests.post(send_url, json=payload).json()
        if send_res.get("errcode") == 0:
            return {"status": "success", "message": "消息发送成功"}
        else:
            return {"status": "error", "message": "微信接口返回错误", "weixin_error": send_res}
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"发送消息发生异常: {str(e)}")
  • 测试命令

    curl -G "http://localhost:8000/webhook" --data-urlencode "msg=测试消息"
    
  • 测试结果(报错)
    {"errcode":40096,"errmsg":"invalid string value '7881299759956504'. invalid external userid..."}
  • 过程分析
    微信服务器拒绝了传入的纯数字 ID。这表明从企业微信网页后台直接查看并抓取的 data-id 仅仅是前端组件库的内部节点 ID,并非 API 接口认可的、以 wmwo 开头的标准加密外部用户 ID(external_userid)。
  • 试错尝试
    尝试通过旧版接口调试工具和后台 Excel 导出功能寻找该加密 ID,均由于腾讯的前端合规脱敏机制导致失败。

阶段二:客服客户列表探测与接口沉默

  • 排错方向
    放弃前端界面寻找,利用已有的合法 Token,编写 Python 脚本直接调用微信客服标准的获取客服客户列表接口(kf/customer/list)进行底层数据抓取。
  • 关键代码(get_user.py)
import requests

CORP_ID = "ww71f41256ef5996d0"
KF_SECRET = "wvFzT8Y3Zp1f1huKrClLwzFroQp0MAEmGND2fxhN2aQ"
OPEN_KF_ID = "wk0CqZGwAA2CXWscqmF2ziD1_ndYmMmA"

token_url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={CORP_ID}&corpsecret={KF_SECRET}"
token = requests.get(token_url).json().get("access_token")

list_url = f"https://qyapi.weixin.qq.com/cgi-bin/kf/customer/list?access_token={token}"
payload = {"open_kfid": OPEN_KF_ID}

res = requests.post(list_url, json=payload).json()
print(res)
  • 测试结果(报错)
    发生网络异常: Expecting value: line 1 column 1 (char 0)
  • 过程分析
    报错原因为 requests.json() 无法解析非 JSON 格式的返回内容。经确认,微信服务器在此处直接返回了空字符串。对于未完全认证的企业号,微信在底层对该接口实施了“沉默拦截”,拒绝返回数据。

阶段三:转向外部联系人接口与寻找核心 UserID

  • 排错方向
    避开受限的客服客服分支,转而调用企业微信最底层的通用基础接口:获取外部联系人列表(externalcontact/list,尝试通过指定内部员工账号来间接获取其名下的客户加密 ID。
  • 关键代码(外部联系人探测脚本)
import requests

CORP_ID = "ww71f41256ef5996d0"
KF_SECRET = "wvFzT8Y3Zp1f1huKrClLwzFroQp0MAEmGND2fxhN2aQ"
TEST_USERID = "1688857913709778" # 使用了网页源码中抠出的成员虚拟ID (vid)

token_url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={CORP_ID}&corpsecret={KF_SECRET}"
token = requests.get(token_url).json().get("access_token")

list_url = f"https://qyapi.weixin.qq.com/cgi-bin/externalcontact/list?access_token={token}&userid={TEST_USERID}"
response = requests.get(list_url)
print(response.json())
  • 测试结果(报错)
    {"errcode":40058,"errmsg":"missing field 'userid'..."} 或传入 vid 后返回 {"errcode":60111,"errmsg":"userid not found"}
  • 过程分析
    接口响应状态码为 200 且返回了标准错误 JSON,证明该基础接口功能未被屏蔽。但微信有严格的参数校验:
  • 调用必须传入添加了该客户的内部员工真实账号(userid)。
  • 之前传入的纯数字 1688857913709778 仅是前端生成的内部虚拟 ID(vid),不是系统主键 userid

阶段四:通讯录接口全量账号探测

  • 排错方向
    为了彻底查明内部员工的系统主键账号(userid),编写脚本调用企业微信的获取部门成员详情接口(user/list,遍历根部门下的所有员工,打印出其真实账号。
  • 关键代码(通讯录全量获取脚本)
import requests

CORP_ID = "ww71f41256ef5996d0"
KF_SECRET = "wvFzT8Y3Zp1f1huKrClLwzFroQp0MAEmGND2fxhN2aQ"

token_url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={CORP_ID}&corpsecret={KF_SECRET}"
token = requests.get(token_url).json().get("access_token")

# department_id=1 代表根部门,fetch_child=1 代表递归获取所有子部门成员
user_list_url = f"https://qyapi.weixin.qq.com/cgi-bin/user/list?access_token={token}&department_id=1&fetch_child=1"
res = requests.get(user_list_url).json()

for user in res.get("userlist", []):
    print(f"员工姓名: {user.get('name')}  ======>  真正的账号(userid): {user.get('userid')}")
  • 测试结果(成功)
    成功捞出底层真实的明文映射关系:
  • 员工姓名: 赵朋朋 ======> 真正的账号(userid): ZhaoPengPeng
  • 员工姓名: 钛合心 ======> 真正的账号(userid): TaiHeXin

阶段五:权限边界验证与跨界拦截

  • 排错方向
    获取到真实的员工账号 ZhaoPengPeng 后,在终端通过单行流命令组合,快速验证当前持有的密钥是否具备跨模块读取外部联系人列表的权限。
  • 关键代码(终端一行流命令)
TOKEN=$(curl -s "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=ww71f41256ef5996d0&corpsecret=wvFzT8Y3Zp1f1huKrClLwzFroQp0MAEmGND2fxhN2aQ" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p') && curl -s "https://qyapi.weixin.qq.com/cgi-bin/externalcontact/list?access_token=$TOKEN&userid=ZhaoPengPeng"
  • 测试结果(报错)
    {"errcode":48002,"errmsg":"api forbidden..."}
  • 过程分析
    触发了腾讯最底层的安全越权拦截。当前容器和脚本持有的 KF_SECRET(微信客服密钥)拥有严格的域限制,被禁止跨界调用企业全局的外部联系人(externalcontact)接口。至此证明,死磕客服消息链路去获取 external_userid 的路已被彻底封死。

阶段六:最终架构确立(自建应用消息通道)

  • 排错方向
    彻底放弃外部客服通知路径。既然已经掌握了企业内部成员的真实 userid(如 TaiHeXin),且自建应用具有完整的应用发信权限,直接切入最稳固的应用消息发送接口(message/send,将消息定向推送到对应内部员工的企业微信/个人微信上。
  • 关键代码(最终发送版 app.py)
import os
import requests
from fastapi import FastAPI, HTTPException

app = FastAPI()

CORP_ID = os.environ.get("CORP_ID", "")
KF_SECRET = os.environ.get("KF_SECRET", "")
TO_USER_ID = os.environ.get("TO_USER_ID", "")

@app.get("/webhook")
def send_wechat_msg(msg: str):
    if not msg:
        raise HTTPException(status_code=400, detail="msg 参数不能为空")
        
    # 1. 获取通用的全局 Access Token
    token_url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={CORP_ID}&corpsecret={KF_SECRET}"
    try:
        token_res = requests.get(token_url).json()
        token = token_res.get("access_token")
        if not token:
            return {"status": "error", "message": "获取Token失败", "weixin_error": token_res}
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"请求Token发生异常: {str(e)}")

    # 2. 调用企业微信核心的自建应用消息发送接口
    send_url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}"
    payload = {
        "touser": TO_USER_ID,        # 此处直接填入员工账号(如 TaiHeXin),多账号用竖线 | 隔开
        "msgtype": "text",
        "agentid": 1000002,          # 自建应用在后台对应的 AgentId
        "text": {
            "content": msg
        },
        "safe": 0
    }
    
    try:
        send_res = requests.post(send_url, json=payload).json()
        if send_res.get("errcode") == 0:
            return {"status": "success", "message": "消息发送成功"}
        else:
            return {"status": "error", "message": "应用消息接口返回错误", "weixin_error": send_res}
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"发送应用消息发生异常: {str(e)}")
  • 环境配置与部署脚本(r.sh)
podman run -d \
  --name myz \
  -p 8000:8000 \
  -m 50m \
  -e CORP_ID="ww71f41256ef5996d0" \
  -e KF_SECRET="wvFzT8Y3Zp1f1huKrClLwzFroQp0MAEmGND2fxhN2aQ" \
  -e TO_USER_ID="TaiHeXin" \
  localhost/zwechat-adapter:v1
  • 部署与终极通车测试

    podman build -t localhost/zwechat-adapter:v1 .
    sh r.sh
    curl -G "http://localhost:8000/webhook" --data-urlencode "msg=应用通道全线通车"
    
  • 最终状态:终端返回 {"status":"success"},自建应用成功、实时地向手机端推送了文本消息,全线通车。

三、 核心排错经验总结

  1. 拒绝盲信前端源码标识:企业微信管理后台 HTML 源码中包含的大量长数字(如 data-iddata-vid)大多仅为前端组件库的虚拟标识,在编写涉及接口调用的底层脚本时,必须使用系统的标准账号主键(userid)。
  2. 严防 Secret 权限域鸿沟:企业微信的各类 Secret(应用、通讯录、微信客服)在腾讯底层有极严格的沙箱隔离。引发 48002 api forbidden 错误时,说明当前凭证尝试了跨模块越权访问,需审视接口与凭证的对应关系。
  3. 低资质企业的最佳通路:在企业资质或认证受限的前提下,利用自建应用的 message/send 接口直接对内部通讯录成员发信,能够有效绕过外部客户、客服接口的各种风控拦截与脱敏规则,是快速落地自动化通知中心的最优方案。

标签: none

添加新评论