公众号分享卡片出去是灰色链接:JS-SDK 自定义分享配置与 invalid signature 排查

2026-09-30 01:28:23 0 次浏览
微信公众号JS-SDK分享前端开发踩坑

上周三下午,运营在群里甩来一张截图:活动页转发到群里,卡片是一行灰色标题加蓝色链接,配图和摘要全没有。前端同事信誓旦旦说 wx.updateAppMessageShareData 调了,后端也说签名接口返回正常。我接手排查,从 wx.error 里挖出 invalid signature,到傍晚定位到根因——测试环境两台机器各自缓存了 access_token,jsapi_ticket 是用不同的 token 换的,导致签名时用的 ticket 与微信侧记录不一致。整个过程踩了五个坑,这篇把它们一次讲清。

一、先分清:灰色链接的两种可能

分享出去是灰链,只有两种情况:

公众号分享卡片出去是灰色链接:JS-SDK 自定

  • JS-SDK 没生效:wx.config 校验失败(invalid signature、invalid url domain 等),自定义分享从未注册成功,微信兜底用页面标题和首图拼一个默认样式。
  • 注册了但时机不对:config 成功了,但 updateAppMessageShareData 调用早于 wx.ready,或者分享动作发生在注入完成之前。

判断方法很直接:在 wx.error 里打印 err 信息,在 wx.ready 里打一条日志。 ready 没触发就是前者,触发了还灰链就是后者。90% 的灰链案例属于前者,而前者里 90% 是签名问题。

flowchart TD
    A[分享出去是灰色链接] --> B{wx.ready 是否触发}
    B -- 否 --> C{wx.error 报什么}
    C -- invalid signature --> D[签名链路排查]
    C -- invalid url domain --> E[检查 JS 安全域名配置]
    C -- 没有报错 --> F[检查 wx.config 参数拼写]
    B -- 是 --> G[检查分享注册时机]
    G --> H[updateXxxShareData 必须在 ready 回调里或之后调用]
    D --> I[核对 URL / ticket / 算法]
    I --> J[修复后重新验证]

二、invalid signature 的完整排查路径

invalid signature 的含义是:你传给 wx.config 的 signature,与微信服务端按同样参数算出来的值对不上。它是一个纯服务端比对面,排查只需要盯住四个变量:ticket、URL、noncestr、timestamp。其中 timestamp 和 noncestr 是你随手生成的,几乎不会错,所以真正的雷区在 ticket 和 URL。

2.1 第一步:确认签名 URL 与当前页面 URL 严格一致

签名算法里的 url 参数,必须是发起分享时浏览器地址栏的完整 URL,去掉 # 及其后部分,不做任何 encode。这是整个链路里出错率最高的一环。

  • Android 微信里,location.href.split('#')[0] 拿到的就是当前页 URL,直接用没问题。
  • iOS 微信有一个历史悠久的坑:SPA(Single Page Application)路由推入新页面后,iOS 的微信初始化 config 时用的 URL 仍是首次进入应用时的那个 URL,而不是地址栏当前值。也就是说,用户从 /home 推到 /activity,你拿 /activity 去签名,iOS 上必挂 invalid signature。
  • 解决办法是 iOS 端用进入应用那一刻记录的 URL 去请求签名。常见做法是在应用入口处把 window.location.href.split('#')[0] 存到全局变量或 sessionStorage,iOS 走这份,Android 走实时的。
// 伪代码:区分平台取签名 URL
// 这段逻辑建议放在应用入口文件里,只执行一次
var initUrl = window.location.href.split('#')[0]; // 入口处记录一次
// 记录时机要早于任何路由跳转,否则拿到的就不是初始 URL 了
var isIOS = /(iPhone|iPad|iPod)/i.test(navigator.userAgent);
// iOS 微信用初始 URL 签名,Android 用当前 URL 签名
var signUrl = isIOS ? initUrl : window.location.href.split('#')[0];
// 把 signUrl 存到全局,供签名请求与 wx.config 共用
// 注意:hash 路由的 # 后部分不参与签名,这里统一剥掉
window.__SIGN_URL__ = signUrl;

还有一类隐蔽情况:页面 URL 带动态参数,后端签名时用的 URL 是运营在后台手填的,和线上实际带参 URL 差一个字符都不行。签名 URL 必须由前端实时取、实时传给签名接口,不要在服务端写死。

2.2 第二步:确认 jsapi_ticket 的获取与缓存

jsapi_ticket 通过 access_token 换取,官方文档明确要求两者都要全局缓存,ticket 有效期 7200 秒,且有每日获取次数限制。这个环节的典型事故:

  • 多实例不共享缓存:两台机器(或容器重建后)各自请求 access_token,后一次请求会使前一次的 token 失效吗?不会,access_token 新旧并存一段时间,但如果你拿一台机器新换的 token 去取 ticket,而另一台机器用旧 token 签名,两边 ticket 不一致,签名就废了。我们上次的事故正是如此——测试环境 node 进程和 Python 脚本各缓存了一份。
  • 刷新后未同步:token 到期刷新时,ticket 没有跟着刷新,服务还在拿过期 ticket 签名,报错同样是 invalid signature(有些时段报 -4001 invalid ticket,注意区分)。

正确姿势:token 和 ticket 存在共享存储(Redis / 数据库),带过期时间,取之前先读缓存。

sequenceDiagram
    participant FE as H5 前端
    participant SRV as 签名接口
    participant RDS as Redis 缓存
    participant WX as 微信服务端
    FE->>SRV: GET /wx/sign?url=当前页URL
    SRV->>RDS: 读 jsapi_ticket
    alt 缓存命中且未过期
        RDS-->>SRV: 返回 ticket
    else 缓存失效
        SRV->>RDS: 读 access_token
        SRV->>WX: GET /cgi-bin/ticket/getticket?type=jsapi
        WX-->>SRV: 返回新 ticket
        SRV->>RDS: 写回缓存(过期 7000 秒)
    end
    SRV->>SRV: 按 key=value 拼串 + sha1
    SRV-->>FE: 返回 appId / timestamp / nonceStr / signature
    FE->>WX: wx.config 提交四个参数
    WX-->>FE: 校验通过触发 wx.ready

2.3 第三步:核对签名算法本身

签名串的拼接规则是固定的四段,按 key 的字典序:jsapi_ticket=...&noncestr=...&timestamp=...&url=...,然后做 SHA1。注意:

  • 拼接串里的 url 不做 URL encode,保持原样。
  • 四个 key 的顺序是字典序,不能按你传参的顺手顺序来。
  • SHA1 输出十六进制小写。

三、服务端签名接口实现

3.1 Node.js 版(Express + Redis)

依赖与环境:Node.js 18+,express 4.x,ioredis 5.x,axios 1.x。

// server.js — 公众号 JS-SDK 签名接口
// 生产环境务必通过环境变量注入敏感配置,别硬编码
const express = require('express');          // Web 框架
const axios = require('axios');              // HTTP 客户端
const crypto = require('crypto');            // 内置模块,算 sha1 用
const Redis = require('ioredis');            // 缓存 token 与 ticket
const redis = new Redis(process.env.REDIS_URL);   // 单例 Redis 连接
const app = express();

const APP_ID = process.env.WX_APPID;         // 公众号 AppID,从环境变量读取
const APP_SECRET = process.env.WX_SECRET;    // AppSecret 绝不能写进代码仓库

// 带 Redis 缓存的 access_token 获取
// 所有实例都从这里取 token,保证全局一致
// 千万不要在请求处理过程中现取 token,限频会撞墙
async function getAccessToken() {
  const cached = await redis.get('wx:access_token');
  if (cached) return cached;                            // 命中缓存直接返回
  const url = 'https://api.weixin.qq.com/cgi-bin/token';
  // grant_type 固定为 client_credential
  const { data } = await axios.get(url, {
    params: { grant_type: 'client_credential', appid: APP_ID, secret: APP_SECRET },
  });
  // 官方失败时返回 errcode 而不是 access_token,必须显式判断
  if (!data.access_token) throw new Error('token 获取失败: ' + JSON.stringify(data));
  // 缓存 7000 秒,比官方 7200 留出余量
  await redis.set('wx:access_token', data.access_token, 'EX', 7000);
  return data.access_token;
}

// 带 Redis 缓存的 jsapi_ticket 获取
// ticket 用 token 换,token 失效会导致换票失败,注意顺序
async function getJsapiTicket() {
  const cached = await redis.get('wx:jsapi_ticket');
  if (cached) return cached;                            // 多实例共享,避免各自换票
  const token = await getAccessToken();
  const { data } = await axios.get(
    'https://api.weixin.qq.com/cgi-bin/ticket/getticket',
    { params: { access_token: token, type: 'jsapi' } }
  );
  // type 固定为 jsapi,别误写成 wx_card
  if (data.errcode !== 0) throw new Error('ticket 获取失败: ' + JSON.stringify(data));
  // ticket 有效期同样按 7200 秒下发,缓存时长留余量
  await redis.set('wx:jsapi_ticket', data.ticket, 'EX', 7000);
  return data.ticket;
}

// 签名接口:前端把当前页 URL 原样传上来
app.get('/wx/sign', async (req, res) => {
  try {
    const rawUrl = req.query.url;                       // 完整 URL,含协议与端口
    // 前端已 encode 传输,Express 会自动解码一次
    const url = rawUrl.split('#')[0];                   // 双保险:再去一次 hash
    const ticket = await getJsapiTicket();
    const nonceStr = Math.random().toString(36).slice(2, 18);
    const timestamp = Math.floor(Date.now() / 1000);    // 必须秒级,不是毫秒
    // 签名串的拼接顺序是定死的,别按自己习惯来
    // 拼好之后做一次 SHA1,输出小写十六进制
    const raw = `jsapi_ticket=${ticket}&noncestr=${nonceStr}&timestamp=${timestamp}&url=${url}`;
    // 四个 key 依次是 ticket、noncestr、timestamp、url
    // url 参数保持原样,不做任何 encode
    // sha1 输出十六进制小写,微信要求的就是这个形态
    const signature = crypto.createHash('sha1').update(raw, 'utf8').digest('hex');
// 排查期建议把这四个值和 raw 串打进日志,与微信侧比对
    // 线上稳定后可降级为只记 signature 和 url
    res.json({ appId: APP_ID, timestamp, nonceStr, signature });
  } catch (e) {
    // 失败时把上游报错透出,别吞掉
    res.status(500).json({ error: e.message });
  }
});

app.listen(3000, () => console.log('sign server on :3000'));
// 上线前自查:AppSecret 走环境变量、Redis 地址走内网、接口加访问频率限制

3.2 Python 版(FastAPI)

依赖与环境:Python 3.10+,fastapi 0.110+,uvicorn,httpx,redis 5.x(异步客户端)。

# sign_server.py — 公众号签名接口(FastAPI 版)
# 启动方式:uvicorn sign_server:app --host 0.0.0.0 --port 8000
import hashlib                          # 标准库,算 sha1
import secrets                          # 生成 nonceStr
import time                             # 秒级时间戳来源
import httpx                            # 异步 HTTP 客户端
from fastapi import FastAPI
from redis import asyncio as aioredis

app = FastAPI()
# decode_responses=True 让 get 返回 str 而不是 bytes
rds = aioredis.from_url("redis://localhost:6379/0", decode_responses=True)

APP_ID = "wx1234567890abcdef"           # 换成你的 AppID
APP_SECRET = "生产环境请从环境变量或配置中心读取"

# token 与 ticket 的 key 命名保持全局统一
# 建议封装成独立模块,供签名接口和其他微信接口复用
# 多服务共用一个 Redis 时更要注意,避免两套缓存并存

async def get_token() -> str:
    cached = await rds.get("wx:access_token")
    if cached:
        return cached                   # 缓存命中,避免频繁调用官方接口
    url = "https://api.weixin.qq.com/cgi-bin/token"
    params = {"grant_type": "client_credential", "appid": APP_ID, "secret": APP_SECRET}
    async with httpx.AsyncClient() as c:
        resp = (await c.get(url, params=params)).json()
    # 官方失败时返回 errcode 字段,这里显式校验
    if "access_token" not in resp:
        raise RuntimeError(f"token 失败: {resp}")
    # 过期时间比官方 7200 秒留 200 秒余量
    await rds.set("wx:access_token", resp["access_token"], ex=7000)
    return resp["access_token"]

async def get_ticket() -> str:
    cached = await rds.get("wx:jsapi_ticket")
    if cached:
        return cached                   # ticket 同样必须全局缓存
    token = await get_token()
    url = "https://api.weixin.qq.com/cgi-bin/ticket/getticket"
    # type=jsapi 是网页分享用的票,wx_card 是卡券,别拿混
    params = {"access_token": token, "type": "jsapi"}
    async with httpx.AsyncClient() as c:
        resp = (await c.get(url, params=params)).json()
    if resp.get("errcode") != 0:
        raise RuntimeError(f"ticket 失败: {resp}")
    await rds.set("wx:jsapi_ticket", resp["ticket"], ex=7000)
    return resp["ticket"]

@app.get("/wx/sign")
# url 通过 query 传入,长度超出浏览器限制时可改 POST
async def sign(url: str):
    # FastAPI 会自动完成 query 参数解码
    url = url.split("#")[0]             # 去掉 hash 部分,与前端约定保持一致
    ticket = await get_ticket()
    nonce = secrets.token_hex(8)        # 16 位随机串
    ts = int(time.time())               # 秒级时间戳
    # 拼接串四个 key 必须按字典序:ticket、noncestr、timestamp、url
# 顺序错一个字符,签名就完全对不上
    raw = f"jsapi_ticket={ticket}&noncestr={nonce}&timestamp={ts}&url={url}"
    # hexdigest 输出小写十六进制,与官方要求一致
    signature = hashlib.sha1(raw.encode("utf-8")).hexdigest()
    # 排查期可临时把 raw 串也返回,用官方校验页比对
    return {"appId": APP_ID, "timestamp": ts, "nonceStr": nonce, "signature": signature}

两份实现的核心逻辑一致:先读缓存,缺了再换,换完写回。签名本身只是十行以内的字符串拼接加一次 SHA1,没有任何玄学,错了一定是四个输入错了。

四、前端接入与排查代码

依赖与环境:JSSDK 版本 1.6.0(支持 updateAppMessageShareData / updateTimelineShareData),页面通过 https://res.wx.qq.com/open/js/jweixin-1.6.0.js 引入。

// share.js — H5 页面分享注入
// 依赖 jweixin-1.6.0.js,低于 1.4 的版本没有 updateXxxShareData 接口
async function setupWxShare(shareData) {
  // 取签名 URL:iOS 与 Android 逻辑不同,见 2.1 节
  const isIOS = /(iPhone|iPad|iPod)/i.test(navigator.userAgent);
  const signUrl = isIOS
    ? window.__INIT_URL__                       // 入口页记录的初始 URL
    : window.location.href.split('#')[0];       // Android 用实时 URL

  // 传给签名接口前 encode 一次,防止参数被截断
  const cfg = await fetch('/wx/sign?url=' + encodeURIComponent(signUrl))
    .then((r) => r.json());                     // 前端 encode 传输,服务端解码后是原文

  // config 是同步提交、异步回调,成败都走事件
  wx.config({
    debug: process.env.NODE_ENV !== 'production', // 测试环境开 debug,会弹每一步结果
    appId: cfg.appId,
    timestamp: cfg.timestamp,                   // 直接透传服务端的秒级时间戳
    nonceStr: cfg.nonceStr,                     // 与签名时用的 nonceStr 是同一个
    signature: cfg.signature,
    jsApiList: ['updateAppMessageShareData', 'updateTimelineShareData'],
  });

  wx.ready(() => {
    // 分享给朋友:卡片主样式
    wx.updateAppMessageShareData({
      title: shareData.title,                   // 卡片标题,32 字以内展示完整
      desc: shareData.desc,                     // 摘要文案
      link: shareData.link,                     // 点击卡片跳转的 URL,须在安全域名下
      imgUrl: shareData.imgUrl,                 // 卡片图,建议 300x300 正方形
    });
    // 分享到朋友圈:只展示标题和图,desc 不生效
    wx.updateTimelineShareData({ title: shareData.title, link: shareData.link, imgUrl: shareData.imgUrl });
  });

  wx.error((err) => {
    // 上线后必留:把 config 失败原因上报到监控
    // invalid signature、invalid url domain 都会在这里出现
    reportToMonitor('wx_config_error', err);
  });
}

debug: true 时微信会在页面顶部弹出每一步的参数和结果,是本地排查期最好用的工具;上线务必关掉。

五、安全域名与 IP 白名单

签名链路之外,还有两处公众号后台配置会直接导致灰链或接口失败:

  • JS 接口安全域名(公众号后台 → 设置与开发 → 公众号设置 → 功能设置):wx.config 的页面域名必须在这里登记,且需要把 MP_verify_xxx.txt 文件放到域名根目录可访问。没配置报 invalid url domain。一个月内每个域名有修改次数限制,改前想清楚。注意这里填的是域名,不带协议不带路径。
  • IP 白名单(基本配置页):调用 access_token 接口的服务器出口 IP 必须在白名单里,否则 gettoken 直接返回 40164 错误码。容器化部署要留意出口 IP 是否固定,弹性伸缩的集群建议走 NAT 网关统一出口。

排查期的 checklist 整理如下,按顺序过一遍,多数 invalid signature 在第三条就能定位:

序号 检查项 常见错误形态 检查方式
1 公众号是否为已认证服务号 订阅号没有自定义分享相关 JS 接口权限 后台查看账号类型
2 JS 安全域名已配置且 verify 文件可访问 报 invalid url domain 浏览器直接访问 txt 文件
3 签名 URL 与当前页 URL 一致(去 # 后) SPA 路由变化、参数被服务端改写 两个 URL 都打日志逐字符比对
4 iOS 用初始 URL、Android 用实时 URL iOS 全量失败、Android 正常 按机型分别验证
5 access_token 全局缓存且未过期 多实例各自取 token 查 Redis 中的键与 TTL
6 jsapi_ticket 全局缓存、随 token 刷新 过期 ticket 签名 调 ticket 接口比对返回值
7 签名串按字典序拼接、url 未 encode 手拼字符串顺序错 服务端打印 raw 串人工核对
8 timestamp 为秒级且与微信服务器时差小 用了毫秒级时间戳 对比本机时间与北京时间
9 服务器出口 IP 在白名单内 gettoken 返回 40164 看签名接口的错误日志
10 share link 域名在安全域名下 卡片可点开但被微信拦截 换测试域名对照验证

六、原理与机制剖析:微信为什么这样设计签名

理解机制能少走弯路。JS-SDK 的签名本质上是一套防篡改 + 防重放的组合:

  • 防篡改:签名串里绑定了当前页 URL,意味着别人不能把你的签名拷贝到自己的域名下用——URL 对不上,SHA1 就对不上。这也解释了为什么签名 URL 必须和页面 URL 严格一致:微信服务端会拿你 config 传来的 URL 参与同一套运算,两边输入差一个字符,输出天差地别。
  • 防滥用:jsapi_ticket 必须用 access_token 换,而 token 的获取受 AppSecret 和 IP 白名单双重保护。ticket 做成短时效且限频,是防止有人绕过公众号体系批量刷分享能力。
  • 无状态校验:wx.config 提交后微信服务端独立复算签名,全程不依赖你先调过什么接口。所以签名错不报在签名接口,而是报在前端 config,这种「错误后置」让很多人在服务端日志里找不到任何异常——服务端确实没异常,错的是喂给算法的输入。

一个容易忽略的细节:wx.config 校验通过后,签名的效力与该次页面加载绑定。SPA 内部路由跳转不产生新的页面加载,理论上不用重新 config;但如果你的 URL 带了会变化的查询参数(比如分享回跳带 code),或刷新了页面,就必须重新走一遍取签名和 config 的流程。保险起见,不少团队在路由每次变化到「可分享页」时都重新注入一次,代价是签名接口的调用量,配合 ticket 缓存完全可接受。

七、效果验证方法

修完之后按这个流程验收,别只在自己手机上看:

  1. 真机验证分安卓和 iOS 各一台,微信版本更新到当前正式版。iOS 的 URL 差异问题只会在 iPhone 上暴露。
  2. 验证两个入口:聊天窗口内转发(看 updateAppMessageShareData 效果)和朋友圈(看 updateTimelineShareData 效果)。朋友圈卡片不显示 desc,属正常。
  3. 用「文件传输助手」当沙箱,反复转发不骚扰别人,截图对比。
  4. 检查 link 域名:卡片点击后落地页必须能正常打开,且域名在安全域名列表内,否则微信会拦截提示。
  5. 图片规格:imgUrl 建议 300×300 以上正方形,小于 300px 或长宽比过大时部分机型会取不到图,回退成灰色截图。

改造前后的对照:

对比维度 改造前(默认灰链) 改造后(自定义分享卡片)
卡片样式 单行灰字标题 + 蓝色链接 大图 + 标题 + 摘要的图文卡片
标题来源 页面 <title> 原文 可定制活动文案,与页面标题解耦
配图 无图或页面截图随机裁切 指定 1:1 封面图,视觉可控
摘要 无 支持一句活动利益点
群内点击率 基线水平 上线当周同渠道明显提升(以自有埋点数据为准)
排查耗时 运营反馈后当天定位 checklist 表格可 10 分钟内过完

表格里最后一行的「明显提升」请以自己产品的埋点为准,不同活动的分享点击率差异很大,不要照抄任何外部数字。

参考与延伸

  • 微信 JS-SDK 官方说明文档(含签名算法与附录排查指引):https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html
  • 获取 access_token 接口说明:https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html
  • 微信网页授权(OAuth2)文档,处理分享回跳带 code 的场景:https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/Wechat_webpage_authorization.html
  • 公众号接口权限说明(账号类型与接口可用性对照):https://developers.weixin.qq.com/doc/offiaccount/Getting_Started/Explanation_of_interface_privileges.html

关键词:公众号开发、微信JS-SDK、自定义分享、invalid signature、签名验证、jsapi_ticket、网页开发、踩坑实录

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。