Skip to content

微信分享

https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html

Nodejs express 服务端

开发必要

node 版本需要大于等 18.x

bash
node -v
v18.16.0

1. 获取公众号 AppID 和 AppSecret

获取 AppID 和 AppSecret 步骤:公众号 - 左侧菜单 设置与开发 - 基本信息 - 公众号开发信息

创建 config.json

Windows

powershell
ni config.json

Mac/Linux 终端

bash
touch config.json

文件内容如下,下面 AppID、AppSecret 需改成你自己的

json
{
  "AppID": "wxe4347189d0252cb8",
  "AppSecret": "0683e7ccb7a9c0ee015fa17dd13f5b2f"
}

2. 获取 access_token 和 jsapi_ticket

获取 access_token

为了实现获取 jsapi_ticket 并使用文件系统进行缓存的功能,我们可以创建一个新的函数来处理这个过程。这个函数将使用由 fetchAccessToken 提供的 access_token 来请求 jsapi_ticket,然后将其存储在一个文件中,并检查是否有效,类似于我们之前对 access_token 的处理。

下面是完整的代码实现,包括了 fetchJsapiTicket 函数,这个函数将依赖于 fetchAccessToken 函数来先获取有效的 access_token

创建 helper.js Windows powershell

powershell
ni helper.js

Mac/Linux 终端

bash
touch helper.js

helper.js 内容如下

javascript
const fs = require("fs").promises;
const config = require("./config.json");
const tokenPath = "./token.json"; // Token 存储路径
const ticketPath = "./ticket.json"; // Ticket 存储路径

let fetchPromise = null; // 全局 promise,确保只有一个 token 请求在进行
let fetchTicketPromise = null; // 全局 promise,确保只有一个 ticket 请求在进行

async function fetchAccessToken() {
  if (fetchPromise) {
    return fetchPromise;
  }

  fetchPromise = (async () => {
    try {
      const currentTime = Math.floor(Date.now() / 1000);
      if (await checkTokenFile(tokenPath, currentTime)) {
        const tokenData = await fs.readFile(tokenPath, "utf8");
        const { access_token } = JSON.parse(tokenData);
        console.log("Using cached token:", access_token);
        return access_token;
      }
      const response = await fetch(
        `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${config.AppID}&secret=${config.AppSecret}`
      );
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      const data = await response.json();

      console.log("[fetchAccessToken data]", data);

      if (data.errcode) {
        throw data;
      }

      const newTokenData = JSON.stringify({
        access_token: data.access_token,
        expires_at: currentTime + data.expires_in,
      });
      await fs.writeFile(tokenPath, newTokenData);
      console.log("New token fetched and cached:", data.access_token);
      return data.access_token;
    } catch (error) {
      console.error("error There was a problem fetching the data:", error);
      throw error;
    } finally {
      fetchPromise = null;
    }
  })();

  return fetchPromise;
}

async function fetchJsapiTicket() {
  if (fetchTicketPromise) {
    return fetchTicketPromise;
  }

  fetchTicketPromise = (async () => {
    try {
      const currentTime = Math.floor(Date.now() / 1000);
      if (await checkTokenFile(ticketPath, currentTime)) {
        const ticketData = await fs.readFile(ticketPath, "utf8");
        const { ticket } = JSON.parse(ticketData);
        console.log("Using cached ticket:", ticket);
        return ticket;
      }
      const access_token = await fetchAccessToken();
      const response = await fetch(
        `https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=${access_token}&type=jsapi`
      );
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }

      const data = await response.json();

      console.log("[fetchJsapiTicket data]", data);

      if (data.errcode) {
        throw data;
      }

      const newTicketData = JSON.stringify({
        ticket: data.ticket,
        expires_at: currentTime + data.expires_in,
      });
      await fs.writeFile(ticketPath, newTicketData);
      console.log("New jsapi ticket fetched and cached:", data.ticket);
      return data.ticket;
    } catch (error) {
      console.error("There was a problem fetching the jsapi ticket:", error);
      throw error;
    } finally {
      fetchTicketPromise = null;
    }
  })();

  return fetchTicketPromise;
}

// Function to check the validity of token or ticket
async function checkTokenFile(filePath, currentTime) {
  try {
    const fileData = await fs.readFile(filePath, "utf8");
    const { expires_at } = JSON.parse(fileData);
    return currentTime < expires_at;
  } catch (error) {
    return false;
  }
}

// Testing the function
// fetchJsapiTicket().then((ticket) => console.log("Jsapi Ticket:", ticket));

module.exports = {
  fetchAccessToken,
  fetchJsapiTicket,
  checkTokenFile,
};

代码说明:

  • fetchJsapiTicket:此函数确保获取 jsapi_ticket 时首先有一个有效的 access_token。它将检查本地文件是否有有效的 jsapi_ticket,如果没有或已过期,则使用有效的 access_token 从微信 API 获取新的 jsapi_ticket 并缓存它。
  • 全局变量fetchTicketPromise 类似于之前的 fetchPromise,确保在任何时候只有一个 jsapi_ticket 请求正在进行。
  • checkTokenFile:通用的函数来检查 token 或 ticket 文件的有效性。

这种设计保证了即使在并发请求的场景下,系统的行为也是正确的,不会导致不必要的 API 请求。

3. 获取 sign 签名代码

下载 sample 并解压出来

点击下载:https://www.weixinsxy.com/jssdk/sample.zip

修改 sample/node/sign.js 文件,把 substr 改为 slice

js
var createNonceStr = function () {
  return Math.random().toString(36).substr(2, 15); 
  return Math.random().toString(36).slice(2, 17); 
};

var createTimestamp = function () {
  return parseInt(new Date().getTime() / 1000) + "";
};

var raw = function (args) {
  var keys = Object.keys(args);
  keys = keys.sort();
  var newArgs = {};
  keys.forEach(function (key) {
    newArgs[key.toLowerCase()] = args[key];
  });

  var string = "";
  for (var k in newArgs) {
    string += "&" + k + "=" + newArgs[k];
  }
  string = string.substr(1); 
  string = string.slice(1); 
  return string;
};

/**
 * @synopsis 签名算法
 *
 * @param jsapi_ticket 用于签名的 jsapi_ticket
 * @param url 用于签名的 url ,注意必须动态获取,不能 hardcode
 *
 * @returns
 */
var sign = function (jsapi_ticket, url) {
  var ret = {
    jsapi_ticket: jsapi_ticket,
    nonceStr: createNonceStr(),
    timestamp: createTimestamp(),
    url: url,
  };
  var string = raw(ret);
  jsSHA = require("jssha");
  shaObj = new jsSHA(string, "TEXT");
  ret.signature = shaObj.getHash("SHA-1", "HEX");

  return ret;
};

module.exports = sign;

修改后的文件如下,创建 sign.js 文件,并写入下面的代码

js
var createNonceStr = function () {
  return Math.random().toString(36).slice(2, 17);
};

var createTimestamp = function () {
  return parseInt(new Date().getTime() / 1000) + "";
};

var raw = function (args) {
  var keys = Object.keys(args);
  keys = keys.sort();
  var newArgs = {};
  keys.forEach(function (key) {
    newArgs[key.toLowerCase()] = args[key];
  });

  var string = "";
  for (var k in newArgs) {
    string += "&" + k + "=" + newArgs[k];
  }
  string = string.slice(1);
  return string;
};

/**
 * @synopsis 签名算法
 *
 * @param jsapi_ticket 用于签名的 jsapi_ticket
 * @param url 用于签名的 url ,注意必须动态获取,不能 hardcode
 *
 * @returns
 */
var sign = function (jsapi_ticket, url) {
  var ret = {
    jsapi_ticket: jsapi_ticket,
    nonceStr: createNonceStr(),
    timestamp: createTimestamp(),
    url: url,
  };
  var string = raw(ret);
  jsSHA = require("jssha");
  shaObj = new jsSHA(string, "TEXT");
  ret.signature = shaObj.getHash("SHA-1", "HEX");

  return ret;
};

module.exports = sign;

4. 初始化 git

创建 .gitignore 文件

bash
touch .gitignore

文件 .gitignore 内容如下

node_modules
sample/
bash
git init
bash
git add .
bash
git commit -m 'first commit'

5. 安装 jssha 模块

安装 jssha 模块

bash
npm init -y
bash
npm install jssha@1.5.0 -S

6. 创建 index.js 并引入 helper.js 的 fetchJsapiTicket 和 sign.js

index.js

js
const { fetchJsapiTicket } = require("./helper.js");
const sign = require("./sign.js");

main();

async function main() {
  const jsapi_ticket = await fetchJsapiTicket();
  const result = sign(jsapi_ticket, "http://docs.ffffee.com");

  //   {
  //     debug: true, // 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印。
  //     appId: '', // 必填,公众号的唯一标识
  //     timestamp: , // 必填,生成签名的时间戳
  //     nonceStr: '', // 必填,生成签名的随机串
  //     signature: '',// 必填,签名
  //     jsApiList: [] // 必填,需要使用的JS接口列表
  //   }

  /*
   *something like this
   *{
   *  jsapi_ticket: 'jsapi_ticket',
   *  nonceStr: '82zklqj7ycoywrk',
   *  timestamp: '1415171822',
   *  url: 'http://example.com',
   *  signature: '1316ed92e0827786cfda3ae355f33760c4f70c1f'
   *}
   */
}

7. 添加 nodemon 和 express 代码

https://www.npmjs.com/package/express

bash
npm i express -S

index.js

js
const express = require("express");
const { fetchJsapiTicket } = require("./helper.js");
const sign = require("./sign.js");
const config = require("./config.json");

const app = express();

app.use(express.json());

app.post("/wechat_share_api/sign", async (req, res) => {
  const requestedUrl = req.body.url; // 使用 req.query 而不是 req.params

  if (!requestedUrl) {
    res.status(400).json({ code: 400, msg: "参数错误" });
    return;
  }
  const jsapi_ticket = await fetchJsapiTicket();

  console.log("jsapi_ticket", jsapi_ticket);
  console.log("Requested URL", requestedUrl); // 显示获取的 URL
  const result = sign(jsapi_ticket, requestedUrl);

  /*
   *result 返回值如下
   *{
   *  jsapi_ticket: 'jsapi_ticket',
   *  nonceStr: '82zklqj7ycoywrk',
   *  timestamp: '1415171822',
   *  url: 'http://example.com',
   *  signature: '1316ed92e0827786cfda3ae355f33760c4f70c1f'
   *}
   */
  res.json({
    debug: true,
    appId: config.AppID,
    url: result.url,
    timestamp: result.timestamp,
    nonceStr: result.nonceStr,
    signature: result.signature,
    jsApiList: ["onMenuShareAppMessage"],
  });
});

app.listen(3001, () => {
  console.log("http://localhost:3001");
});
bash
node index.js

调用尝试签名

http://localhost:3000/sign?url=https://docs.ffffee.com

8. 常见错误以及解决方案

出现下面错误 errcode: 40164,

通过 https://www.ipplus360.com/ 可以查到你自己的 ip,下面根据 https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html 中的文档提示 40164 错误码 是 调用接口的IP地址不在白名单中,请在接口IP白名单中进行设置。

[fetchAccessToken data] {
  errcode: 40164,
  errmsg: 'invalid ip 123.118.72.95 ipv6 ::ffff:123.118.72.95, not in whitelist rid: 663fa27e-51f60287-3755f633'
}
New token fetched and cached: undefined
[fetchJsapiTicket data] {
  errcode: 40001,
  errmsg: 'invalid credential, access_token is invalid or not latest, could get access_token by getStableAccessToken, more details at https://mmbizurl.cn/s/JtxxFh33r rid: 663fa27e-6932e051-79781421'
}

在这里有 配置白名单的 第一步:填写服务器配置https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Access_Overview.html

alt text

通过 https://www.ipplus360.com/ 可以知道自己的 IP(开发环境)

Error: Chosen SHA variant is not supported

遇到下面的错误,原因是 jssha 版本太高,需要改成 sample 的一样的 "jssha": "^1.5.0"

我的版本 - "jssha": "^3.3.1", 改成 "jssha": "^1.5.0"

Error: Chosen SHA variant is not supported
    at new n (C:\Users\Administrator\Desktop\we-share\node_modules\jssha\dist\sha.js:21:21984)
    at sign (C:\Users\Administrator\Desktop\we-share\sign.js:42:12)
    at C:\Users\Administrator\Desktop\we-share\index.js:16:18

Node.js v18.16.0
bash
npm i jssha@1.5.0 -S

调用尝试签名

http://localhost:3000/sign?url=https://docs.ffffee.com

json
{
  "debug": true,
  "timestamp": "1715447994",
  "nonceStr": "sl024m87dh",
  "signature": "d8eb0570a3f6e0668bcbf2ae1325711b37d94ba7",
  "jsApiList": ["updateAppMessageShareData"]
}

https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html#111

未找到原因,解决办法

使用 onMenuShareAppMessage 替代 updateAppMessageShareData 方法

9. 测试页面获取和使用

微信正常分享给其他用户,步骤如下:

  1. 把需要分享的页面 url -> 生成二维码
  2. 用户使用微信扫描二维码 (注意:直接微信打开这个是无法卡片式分享的,目前只能通过扫码后分享才可以)
  3. 用户点击右上角 ... - 分享给好友
  4. 选中好友 此时分享成功

测试例子获取:

附录 6-DEMO 页面和示例代码 https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html#67

这是 demo 代码 https://www.weixinsxy.com/jssdk/

  1. chrome 浏览器打开

  2. ctrl + S 保存页面

    alt text

修改例子

修改前

Details
html
<script>
  /*
   * 注意:
   * 1. 所有的JS接口只能在公众号绑定的域名下调用,公众号开发者需要先登录微信公众平台进入“公众号设置”的“功能设置”里填写“JS接口安全域名”。
   * 2. 如果发现在 Android 不能分享自定义内容,请到官网下载最新的包覆盖安装,Android 自定义分享接口需升级至 6.0.2.58 版本及以上。
   * 3. 常见问题及完整 JS-SDK 文档地址:http://mp.weixin.qq.com/wiki/7/aaa137b55fb2e0456bf8dd9148dd613f.html
   *
   * 开发中遇到问题详见文档“附录5-常见错误及解决办法”解决,如仍未能解决可通过以下渠道反馈:
   * 邮箱地址:weixin-open@qq.com
   * 邮件主题:【微信JS-SDK反馈】具体问题
   * 邮件内容说明:用简明的语言描述问题所在,并交代清楚遇到该问题的场景,可附上截屏图片,微信团队会尽快处理你的反馈。
   */
  wx.config({
    debug: false,
    appId: "wxf8b4f85f3a794e77",
    timestamp: 1715448905,
    nonceStr: "7O21RVGLdDOuZODL",
    signature: "b47d87e7531bfca67e128458dc997d2f9cd24ecb",
    jsApiList: [
      "checkJsApi",
      "onMenuShareTimeline",
      "onMenuShareAppMessage",
      "onMenuShareQQ",
      "onMenuShareWeibo",
      "onMenuShareQZone",
      "hideMenuItems",
      "showMenuItems",
      "hideAllNonBaseMenuItem",
      "showAllNonBaseMenuItem",
      "translateVoice",
      "startRecord",
      "stopRecord",
      "onVoiceRecordEnd",
      "playVoice",
      "onVoicePlayEnd",
      "pauseVoice",
      "stopVoice",
      "uploadVoice",
      "downloadVoice",
      "chooseImage",
      "previewImage",
      "uploadImage",
      "downloadImage",
      "getNetworkType",
      "openLocation",
      "getLocation",
      "hideOptionMenu",
      "showOptionMenu",
      "closeWindow",
      "scanQRCode",
      "chooseWXPay",
      "openProductSpecificView",
      "addCard",
      "chooseCard",
      "openCard",
    ],
  });
</script>

修改后

可以访问 微信 JS-SDK Demo -- 修改后

Details
html
<script>
  /*
   * 注意:
   * 1. 所有的JS接口只能在公众号绑定的域名下调用,公众号开发者需要先登录微信公众平台进入“公众号设置”的“功能设置”里填写“JS接口安全域名”。
   * 2. 如果发现在 Android 不能分享自定义内容,请到官网下载最新的包覆盖安装,Android 自定义分享接口需升级至 6.0.2.58 版本及以上。
   * 3. 常见问题及完整 JS-SDK 文档地址:http://mp.weixin.qq.com/wiki/7/aaa137b55fb2e0456bf8dd9148dd613f.html
   *
   * 开发中遇到问题详见文档“附录5-常见错误及解决办法”解决,如仍未能解决可通过以下渠道反馈:
   * 邮箱地址:weixin-open@qq.com
   * 邮件主题:【微信JS-SDK反馈】具体问题
   * 邮件内容说明:用简明的语言描述问题所在,并交代清楚遇到该问题的场景,可附上截屏图片,微信团队会尽快处理你的反馈。
   */

  const url = window.location.href; // 获取当前的完整URL
  const cleanUrl = url.split("#")[0]; // 使用split方法以#为分割点,并取第一部分
  const decodedUrl = decodeURIComponent(cleanUrl); // 对URL进行解码
  console.log(cleanUrl); // 输出结果

  fetch("/wechat_share_api/sign", {
    method: "POST", // *GET, POST, PUT, DELETE, etc.
    body: JSON.stringify({ url: decodedUrl }),
  })
    .then((res) => {
      console.log("res", res);
      return res.json();
    })
    .then((result) => {
      console.log("[result]", result);
      wx.config({
        debug: true,
        appId: result.appId,
        timestamp: Number(result.timestamp),
        nonceStr: result.nonceStr,
        signature: result.signature,
        jsApiList: [
          "checkJsApi",
          "onMenuShareTimeline",
          "onMenuShareAppMessage",
          "onMenuShareQQ",
          "onMenuShareWeibo",
          "onMenuShareQZone",
          "hideMenuItems",
          "showMenuItems",
          "hideAllNonBaseMenuItem",
          "showAllNonBaseMenuItem",
          "translateVoice",
          "startRecord",
          "stopRecord",
          "onVoiceRecordEnd",
          "playVoice",
          "onVoicePlayEnd",
          "pauseVoice",
          "stopVoice",
          "uploadVoice",
          "downloadVoice",
          "chooseImage",
          "previewImage",
          "uploadImage",
          "downloadImage",
          "getNetworkType",
          "openLocation",
          "getLocation",
          "hideOptionMenu",
          "showOptionMenu",
          "closeWindow",
          "scanQRCode",
          "chooseWXPay",
          "openProductSpecificView",
          "addCard",
          "chooseCard",
          "openCard",
        ],
      });
    })
    .catch((error) => {
      console.error("[error]", error);
    });
</script>

解决微信分享 config:fail,invalid signature - 有中文参数 - 签名失败的问题

错误提示: config:fail,invalid signature

正确的方式:

  1. 前端显示的路径,如果存在中文参数,那么需要 使用 encodeURIComponent 对中文字符串进行编码

假如有这样子的 url

js
const str = `http://localhost:5173/detail.html?path=/api/image/p2904477111.webp&title=飞驰人生2&region=中国大陆&genres=剧情喜剧运动&director=韩寒`;

需要改成

js
const title = encodeURIComponent("飞驰人生2");
const region = encodeURIComponent("中国大陆");
const genres = encodeURIComponent("剧情喜剧运动");
const director = encodeURIComponent("韩寒");
const str = `http://localhost:5173/detail.html?path=/api/image/p2904477111.webp&title=${title}&region=${region}&genres=${genres}&director=${director}`;
  1. 前端发送给后端的参数(一般 post 请求) 内容如下, 后端无需 decodeURIComponent
json
{
  "url": "https://docs.ffffee.com/wechat/mobile/detail.html?path=/api/image/p2903145026.webp&title=%E7%AC%AC%E4%BA%8C%E5%8D%81%E6%9D%A1&region=%E4%B8%AD%E5%9B%BD%E5%A4%A7%E9%99%86&genres=%E5%89%A7%E6%83%85%E5%96%9C%E5%89%A7%E5%AE%B6%E5%BA%AD&director=%E5%BC%A0%E8%89%BA%E8%B0%8B"
}