公众号的两条主线
公众号开发看起来接口很多,其实只有两条主线:一条是微信把用户消息和事件推给你的服务器,你在限定时间内回一条消息;另一条是用户从公众号菜单或图文进入你的网页,你通过网页授权拿到他的身份。前者是被动的消息循环,后者是主动的身份获取,其余的接口基本都是围绕这两条主线提供素材、菜单、模板等外围能力。
公众号服务端的核心约束只有一句话:微信发过来的每个请求,你都要在数秒内应答;而你需要主动发起的每次调用,都依赖一个必须缓存、不能频繁刷新的 access_token。
先接受这个约束模型,再动手写代码,会比一上来就翻接口文档轻松很多。下一篇安排上,我们从服务器配置开始,把“验签”这道门先推开。
服务器配置:验签与消息加解密
在公众号后台填写服务器地址、Token 之后,微信会立刻向你的地址发一个 GET 请求做校验,参数包含 signature、timestamp、nonce 和 echostr。你需要在服务端复现签名算法:把 Token、timestamp、nonce 三个值按字典序排序后拼成一个字符串,做 SHA1,与 signature 比较,一致则原样返回 echostr。
// server/wechat.js —— 服务器地址校验(Node.js,框架无关)
const crypto = require("crypto");
function checkSignature({ signature, timestamp, nonce }) {
const TOKEN = process.env.WECHAT_TOKEN; // 与后台填写的一致
const raw = [TOKEN, timestamp, nonce].sort().join("");
return crypto.createHash("sha1").update(raw, "utf8").digest("hex") === signature;
}
// 以 Express 为例
app.get("/wechat", (req, res) => {
const ok = checkSignature(req.query);
res.send(ok ? req.query.echostr : "signature error");
});
三个常见失败原因:Token 里含有空格导致两边不一致;服务器前面挂了反向代理,但没有把 query 完整透传;用了 HTTPS 但证书链不完整,微信侧请求直接失败。校验通过之后再考虑消息加解密方式——如果选择“安全模式”,所有消息体都是加密的,需要 EncodingAESKey 与 AES-CBC 解密,初期建议先用明文模式把流程跑通。
接收消息与被动回复
用户给公众号发消息时,微信会以 XML 形式 POST 到同一个地址。你的任务是解析 XML、执行业务逻辑,并在 5 秒内返回一段回复 XML。注意回复里的 ToUserName 与 FromUserName 必须互换。
// 依赖:npm i fast-xml-parser
const { XMLParser, XMLBuilder } = require("fast-xml-parser");
const parser = new XMLParser({ ignoreAttributes: false });
function buildTextReply(to, from, content) {
return new XMLBuilder({ format: false }).build({
xml: {
ToUserName: to, // 用户 openid
FromUserName: from, // 公众号原始 ID
CreateTime: Math.floor(Date.now() / 1000),
MsgType: "text",
Content: content
}
});
}
app.post("/wechat", (req, res) => {
const xml = (req.body && req.body.xml) || {};
const { FromUserName, ToUserName, MsgType, Content, Event } = xml;
if (MsgType === "text") {
const reply = Content.includes("签到")
? "签到成功,今日积分 +5。"
: "试试回复「签到」两个字。";
return res.type("application/xml").send(buildTextReply(FromUserName, ToUserName, reply));
}
if (MsgType === "event" && Event === "subscribe") {
return res.type("application/xml")
.send(buildTextReply(FromUserName, ToUserName, "欢迎关注,回复「签到」开始累计积分。"));
}
res.send("success"); // 不回复也要应答 success,避免微信重试
});
这里有几个经验值值得记住:处理耗时可能超过 5 秒时,正确做法是先返回 success,再通过客服消息接口异步推送结果;如果连续三次都没有拿到你的应答,微信会断开并重试,用户侧会看到“该公众号暂时无法提供服务”。此外,XML 中如果出现 CDATA,解析器要按字符串处理,不要当成嵌套结构。
网页授权:换取用户身份
网页授权是公众号里唯一能拿到用户 openid 甚至昵称头像的官方路径。它有两种 scope:snsapi_base 静默授权、只能拿 openid,适合做身份识别;snsapi_userinfo 会弹出授权页、能拿昵称头像,适合需要展示用户信息的场景。
// 第一步:拼接授权地址,引导用户跳转
const appid = process.env.WX_APPID;
const redirect = encodeURIComponent("https://m.example.com/wx/callback");
const state = "signin_" + Date.now(); // 用于防 CSRF,回调时要校验
const authUrl = "https://open.weixin.qq.com/connect/oauth2/authorize" +
`?appid=${appid}&redirect_uri=${redirect}` +
"&response_type=code&scope=snsapi_userinfo" +
`&state=${state}#wechat_redirect`;
// 第二步:回调里用 code 换 access_token 与 openid
async function exchange(code) {
const url = "https://api.weixin.qq.com/sns/oauth2/access_token" +
`?appid=${appid}&secret=${process.env.WX_APPSECRET}` +
`&code=${code}&grant_type=authorization_code`;
const r = await fetch(url).then((x) => x.json());
return r; // { access_token, openid, scope, refresh_token }
}
// 第三步:scope 为 snsapi_userinfo 时拉取用户资料
async function getUserInfo(accessToken, openid) {
const url = "https://api.weixin.qq.com/sns/userinfo" +
`?access_token=${accessToken}&openid=${openid}&lang=zh_CN`;
return fetch(url).then((x) => x.json());
}
最容易踩的坑是把两套 access_token 搞混:网页授权拿到的是“网页授权专用 access_token”,只在 sns 系列接口有效,且随 code 一次性获取;而 /cgi-bin/ 系列接口用的是“全局 access_token”,需要全局缓存、统一刷新。把这两者写进同一个变量名,是新手最常见的线上事故来源。
素材管理与 JSSDK 签名
需要发布图文、上传图片视频时,会用到素材接口。临时素材有效期只有三天,适合发一次性的客服消息;永久素材有数量上限但不会过期,适合公众号文章的封面与正文图片。上传素材同样需要全局 access_token,因此缓存机制必须先做好。
// 全局 access_token 的缓存与刷新(进程内简单实现)
let cache = { token: "", expireAt: 0 };
async function getAccessToken() {
const now = Date.now();
if (cache.token && now < cache.expireAt - 60_000) return cache.token; // 提前 60 秒过期
const url = "https://api.weixin.qq.com/cgi-bin/token" +
`?grant_type=client_credential&appid=${process.env.WX_APPID}` +
`&secret=${process.env.WX_APPSECRET}`;
const r = await fetch(url).then((x) => x.json());
if (!r.access_token) throw new Error("获取 access_token 失败:" + JSON.stringify(r));
cache = { token: r.access_token, expireAt: now + r.expires_in * 1000 };
return cache.token;
}
// JSSDK 签名:注意 url 必须是当前页面的完整 URL,且不含 # 及其后的片段
function jsapiSign(jsapiTicket, noncestr, timestamp, url) {
const raw = `jsapi_ticket=${jsapiTicket}&noncestr=${noncestr}` +
`×tamp=${timestamp}&url=${url.split("#")[0]}`;
return crypto.createHash("sha1").update(raw, "utf8").digest("hex");
}
JSSDK 签名失败的两个高频原因都在 url 上:一是把域名之后的参数丢掉,导致与实际地址不一致;二是页面用了前端路由,签名时用的是配置的地址而不是用户看到的地址。记住一个原则——谁在地址栏里,就以谁为准。
接口配额与治理策略
公众号接口的调用量是有限的,因此“怎么用”和“用了多少”同样重要。
| 接口族 | 凭据 | 有效期/配额要点 |
|---|---|---|
| /cgi-bin/*(素材、菜单、客服) | 全局 access_token | 有效期 2 小时,需全局缓存;每天获取次数有限,不可每次请求都刷新 |
| /sns/*(网页授权、用户资料) | 网页授权 access_token | 由 code 换取,随用户会话使用,与全局 token 完全独立 |
| /cgi-bin/ticket/getticket | jsapi_ticket | 有效期 2 小时,用于 JSSDK 签名,同样需要缓存 |
| 消息回复 | 无需凭据 | 5 秒内应答,超时会触发重试 |
- 集中式中控多进程部署时把 token 缓存放到 Redis,并提供带锁的刷新函数,避免同时刷新导致互相失效。
- 提前过期缓存时预留 60 秒以上余量,网络抖动时不至于拿到已失效的凭据。
- 错误码分级40001/42001 表示凭据无效需强制刷新,45009 表示超频需退避,两者处理方式完全不同。
实战:签到积分的最小闭环
把上面的零件拼起来,就是一个可用的服务号小应用:用户回复“签到”,积分累加;从菜单进入网页,能看到自己的积分明细。
- 用户表:主键用 openid,字段包含昵称、积分、最近签到日期。openid 是同一用户在同一公众号下的稳定标识,可以直接当业务主键。
- 签到逻辑:判断“最近签到日期”是否是今天,防止重复累加;同时写入一条积分流水,便于对账与排查。
- 网页端:静默授权拿 openid,再查自己的积分;只有需要展示昵称头像时才升级为 snsapi_userinfo。
- 后台定时任务:每天拉取用户增长与消息量,观察是否有异常刷签到行为。
// 签到:先查后写,保证一天只加一次
async function signIn(openid) {
const today = new Date().toISOString().slice(0, 10);
const user = await db.user.findUnique({ where: { openid } });
if (user && user.lastSignDate === today) return { ok: false, msg: "今天已经签到过了" };
const [u] = await db.$transaction([
db.user.upsert({
where: { openid },
update: { points: { increment: 5 }, lastSignDate: today },
create: { openid, points: 5, lastSignDate: today }
}),
db.pointLog.create({ data: { openid, delta: 5, reason: "签到", day: today } })
]);
return { ok: true, points: u.points, msg: "签到成功,今日积分 +5" };
}
注意这里用了数据库事务:用户余额变更与流水写入必须一起成功,否则对账时就说不清这 5 分是从哪来的。这个习惯在小程序、公众号、任何涉及虚拟资产的场景里都适用。