前阵子帮一个做客服中台的团队排查问题:他们接口调了三天,一直是鉴权失败,换了四种写法都没通。最后发现是方向选错了——他们要的是"让员工的企业微信账号自动给客户发消息",但照着官方开放平台的文档在写。这两条路从鉴权方式到接口形态完全不是一回事,代码写得再对也调不通。
这篇文章把企业微信对接的底层概念一次讲清:账号体系里那些 ID 分别是什么、调用凭证怎么拿怎么传、请求模型长什么样。看完你应该能在半小时内判断出自己该走哪条路,并且把第一个请求发通。
一、先看结论:企业微信开发有两条完全不同的路
这是整篇文章最重要的一节。很多人卡住不是因为代码写得差,而是选错了方向。
| 对比项 | 官方开放平台 API | 账号通道型 API |
|---|---|---|
| 接入主体 | 企业(一个企业一个 corpId) | 账号(一个登录的企微账号一个实例标识) |
| 身份 | 企业管理员在后台建应用、配权限 | 账号持有人本人扫码授权 |
| 凭证 | corpid + secret 换 access_token | 控制台领取的固定调用凭证 |
| 能做什么 | 应用消息、通讯录、审批、打卡等企业级能力 | 收发客户消息、管理客户群、朋友圈等账号级动作 |
| 消息方向 | 应用推送给成员(企业 → 员工) | 以员工身份与客户对话(员工 ↔ 客户) |
| 场景 | 内部办公、审批流、自建应用 | 客服、SCRM、私域运营、会话存档 |
一句话判断:如果你的需求是"企业发通知给员工",走官方开放平台;如果是"让某个员工的企微账号去跟客户聊天/拉群/发朋友圈",官方开放平台做不到,得走账号通道型。
官方开放平台的能力边界很清晰:它开放的是"企业能管什么",不包含"以员工个人身份对外沟通"。这不是权限没申请够,是设计上就不开放。
二、账号体系:这些 ID 必须分清
企业微信里的 ID 种类多,混用是最高频的错误来源。先记住这张表:
| 标识 | 含义 | 作用域 | 常见坑 |
|---|---|---|---|
corpId | 企业 ID | 整个企业唯一 | 和 corpid 拼写混用,字段大小写敏感 |
userId | 成员账号 | 企业内部唯一 | 不同企业间可重复,跨企业关联必须带 corpId |
external_userid | 外部联系人(客户)ID | 对某个成员唯一,不是全局唯一 | 同一个客户被两个成员添加,是两个不同的 external_userid |
agentId | 应用 ID | 企业内唯一 | 发应用消息必填 |
openid | 客户在某个应用下的 ID | 应用维度 | 和 external_userid 是两套体系,别混 |
chatId / roomId | 会话 / 群 ID | 群维度 | 群成员变动、群重建可能导致 ID 变化 |
uin | 账号自身的数字 ID | 登录实例维度 | 通常是 int64,见第四节 |
external_userid 那个坑值得单独说
我见过不止一个团队在这上面翻车:他们按 external_userid 做客户主数据主键,结果同一个真实客户在系统里出现了七八条记录。
原因是 external_userid 的定义是"某成员与某客户的关系 ID",不是客户本身的 ID。同一个客户被公司 7 个销售加过,就有 7 个 external_userid。
正确做法:客户主表用 unionid(同一主体下客户唯一,需企业认证)或自己生成主键,external_userid 只作为"哪个员工对接这个客户"的关系字段存。
三、调用凭证:怎么拿、怎么传、怎么存
官方开放平台:access_token
GET https://qyapi.weixin.qq.com/cgi-bin/gettoken
?corpid={CORPID}&corpsecret={SECRET}
返回:
{
"errcode": 0,
"errmsg": "ok",
"access_token": "xxx",
"expires_in": 7200
}
三个必须遵守的约束:
- 必须缓存,不能每次调用都去换。官方对 gettoken 有频率限制,高频调用会被限流;正确做法是全局缓存一份,提前 5 分钟刷新。
- 必须全局唯一。多个服务实例各自刷新 token,会导致后刷新的把先刷新的顶掉,出现随机鉴权失败。要么用单点刷新 + 共享缓存(Redis),要么加分布式锁。
- 不同应用的 secret 换出来的是不同 token。通讯录的 secret 换出的 token 不能用来发应用消息。
一个能用的 Java 缓存实现:
@Component
public class TokenManager {
private static final long REFRESH_AHEAD_MS = 5 * 60 * 1000L;
private final RedisTemplate<String, String> redis;
private final String redisKey = "wecom:token:" + appId;
public String getToken() {
String cached = redis.opsForValue().get(redisKey);
if (cached != null) {
return cached;
}
// 分布式锁,避免并发刷新互相顶掉
RLock lock = redissonClient.getLock(redisKey + ":lock");
lock.lock(10, TimeUnit.SECONDS);
try {
// 双重检查
cached = redis.opsForValue().get(redisKey);
if (cached != null) {
return cached;
}
TokenResp resp = fetchFromWecom();
long ttl = resp.getExpiresIn() - 300; // 提前 5 分钟过期
redis.opsForValue().set(redisKey, resp.getAccessToken(), ttl, TimeUnit.SECONDS);
return resp.getAccessToken();
} finally {
lock.unlock();
}
}
}
账号通道型:Bearer 凭证
这类服务把登录实例、消息收发、事件回调都封装成 HTTP 接口,业务侧只需要调接口,不用自己维护登录态——Eyun 企微 API 就属于这一类。它的凭证是控制台领取的固定字符串,放进请求头:
POST {BASE_URL}/api/message/sendText
Authorization: Bearer <你的调用凭证>
Content-Type: application/json
和 access_token 的关键区别:它不会过期,但泄露即等于账号被接管。所以:
- 不进代码仓库,走配置中心或环境变量
- 不进前端,任何前端代码里出现它都是事故
- 日志脱敏,打印请求头时把 Authorization 抹掉
import os, requests
BASE_URL = os.environ["WECOM_BASE_URL"]
TOKEN = os.environ["WECOM_TOKEN"]
def send_text(appid: str, conversation_id: int, content: str):
resp = requests.post(
f"{BASE_URL}/api/message/sendText",
headers={
"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json",
},
json={
"appid": appid,
"conversationId": conversation_id,
"content": content,
},
timeout=10,
)
resp.raise_for_status()
body = resp.json()
if body.get("code") != 0:
raise RuntimeError(f"send failed: code={body.get('code')} msg={body.get('message')}")
return body["data"]
四、请求模型:一个能力一个路径,POST + JSON
无论是哪条路,现代企微接口基本都收敛成了同一个形态:POST + JSON,一个能力一个 URL 路径。
POST {BASE_URL}/api/{模块}/{动作}
模块和动作都是名词化的,看路径就知道在干什么:
| 路径 | 含义 |
|---|---|
/api/message/sendText | 发文本消息 |
/api/message/sendImage | 发图片消息 |
/api/contact/list | 拉联系人列表 |
/api/room/create | 建群 |
/webhook/set | 配置回调地址 |
这个形态的好处是不需要记方法名,接口清单就是目录。坏处是路径多了以后容易拼错,所以建议在项目里用常量集中管理,别到处写裸字符串。
响应封套
统一返回一个信封,业务数据放在 data 里:
{
"code": 0,
"data": { "id": 1730000000000001 },
"detail": "",
"message": "ok",
"time": "2026-10-28 15:04:05"
}
判断成功的正确写法是 code == 0,不要用 HTTP 状态码判断——网络层的 200 只代表请求到了网关,不代表业务成功。很多"我明明返回 200 了为什么没发出去"的问题都出在这。
int64:所有 ID 都是大整数,这是个硬约束
uin、conversationId、roomId、消息 ID 这些都是 int64,取值范围超过 JavaScript 的安全整数范围。
请求时必须传 JSON number,不能传字符串:
// 正确
{ "conversationId": 1688855874759204 }
// 错误,会报类型错误
{ "conversationId": "1688855874759204" }
解析时不能经过浮点数。Python 的 json 模块没有这个问题(Python int 任意精度),但 JavaScript 会:
// 1688855874759204 会被解析成 1688855874759200
JSON.parse('{"conversationId": 1688855874759204}')
// → { conversationId: 1688855874759200 } ← 尾数已经丢了
后端还要注意:Java 里用 Long 接,别用 int;MySQL 里用 BIGINT,别用 INT;序列化给前端时考虑转成字符串(这一层怎么做我单独写了一篇,见文末链接)。
五、第一次调用:一个能跑通的最小流程
按这个顺序走,能避开 90% 的新手问题:
1. 拿到凭证
├─ 官方:corpId + secret → access_token(记得缓存)
└─ 通道:控制台领取 → 存进环境变量
2. 确认实例在线
└─ 通道型需要先扫码登录,账号不在线时所有调用都会失败
3. 给自己发一条消息(最安全的测试)
└─ 不要一上来就给客户发,先给自己的 uin 发
4. 看返回的 code
├─ 0 → 成功,把返回的 message id 记下来(撤回要用)
└─ 非 0 → 按 message 字段查原因,不要瞎改参数重试
5. 配回调地址
└─ 只发不收,等于系统长期处于"大概发出去了"的状态
第 3 步特别说一下:第一个请求永远发给自己。给自己发消息不需要任何客户资源,失败了也不会打扰到别人,是验证凭证、网络、参数格式是否都正确的最快方式。
六、踩坑清单
- 把
access_token写进每个请求的地方重新获取 → 触发限流,且多实例下会互相顶掉。必须全局缓存 + 加锁刷新。 - 用
external_userid当客户主键 → 同一个客户产生多条记录。改用unionid或自建主键,external_userid只作关系字段。 - ID 传成字符串 → 报类型错误。int64 必须传 JSON number。
- 只看 HTTP 状态码判断成功 → 网关层 200 不等于业务成功,必须判断业务
code。 - 凭证进前端或进仓库 → 泄露即账号被接管。走环境变量 + 配置中心,日志脱敏。
- 一上来就批量给客户发 → 触发风控,且失败原因排查困难。先自聊验证,再小批量,最后放开。
- 没配回调就开始上量 → 发出去不知道成没成功,出问题无法对账。回调和发送同等重要,应该一起上线。
小结
企业微信对接的第一道门槛不是代码,是分清你在走哪条路:
- 企业发通知给员工 → 官方开放平台,
corpId+secret换access_token,注意全局缓存 - 员工账号跟客户对话 → 账号通道型,Bearer 凭证,注意别泄露
然后是三个硬约束:int64 传 number 不传字符串、用业务 code 判断成功而不是 HTTP 200、external_userid 是关系 ID 不是客户 ID。这三条占了我见过的新手问题的绝大部分。
下一篇聊具体的调用模型和鉴权细节展开。如果你正在搭企微相关的系统,我这个系列会按「跑通 → 收回调 → 上量 → 存档」的顺序写下去,索引在文末。
系列导航
- 上一篇:—
- 下一篇:《为什么企业微信外部群加不了 AI 机器人:官方限制与三种替代方案》
本文系统梳理企业微信API开发的三大核心:账号体系(区分corpid、userid、external_userid等ID,强调external_userid是关系ID而非客户ID)、调用凭证(官方开放平台使用需缓存与管理的access_token,账号通道型使用需严格保密的Bearer凭证)、请求模型(统一POST+JSON格式,响应需校验errcode而非HTTP状态码,所有ID为int64硬约束)。明确两条开发路径适用场景,帮助开发者快速选型并规避高频踩坑点。
240

被折叠的 条评论
为什么被折叠?



