公众号的两条主线

公众号开发看起来接口很多,其实只有两条主线:一条是微信把用户消息和事件推给你的服务器,你在限定时间内回一条消息;另一条是用户从公众号菜单或图文进入你的网页,你通过网页授权拿到他的身份。前者是被动的消息循环,后者是主动的身份获取,其余的接口基本都是围绕这两条主线提供素材、菜单、模板等外围能力。

公众号服务端的核心约束只有一句话:微信发过来的每个请求,你都要在数秒内应答;而你需要主动发起的每次调用,都依赖一个必须缓存、不能频繁刷新的 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}` +
              `&timestamp=${timestamp}&url=${url.split("#")[0]}`;
  return crypto.createHash("sha1").update(raw, "utf8").digest("hex");
}

JSSDK 签名失败的两个高频原因都在 url 上:一是把域名之后的参数丢掉,导致与实际地址不一致;二是页面用了前端路由,签名时用的是配置的地址而不是用户看到的地址。记住一个原则——谁在地址栏里,就以谁为准。