谈谈微信小程序扫码开放平台的设计与实现

2026年05月17日0 次阅读0 人喜欢
NestJS微信小程序API架构设计后端

最近在折腾一个需求:把微信小程序扫码登录的功能抽出来,做成类似开放平台的东西,让别人也能接入使用。

背景

我之前在 auth 模块里写了一堆小程序扫码相关的接口——生成二维码、查状态、拿用户信息之类的。但这些都是自己内部用的,代码也耦合在一起,谈不上什么复用性。

这次想做的事情其实很简单:

别人在我这里注册一个应用,拿到 appKey 和 appSecret,然后调我的接口生成二维码,用户扫码之后我通知他们结果。

说白了就是一个扫码登录的 SaaS 服务。

整体架构

我把这个功能拆成了一个独立的 open-platform 模块,和原有的 auth 模块完全解耦。

复制代码
外部应用 → 签名调用API → 生成二维码 → 用户扫码
                                              ↓
                              小程序上报用户信息
                                              ↓
                              异步回调 + 轮询查询

外部应用注册之后会拿到 appKeyappSecret,之后所有请求都通过 HMAC-SHA256 签名验证,不用再搞什么账号密码登录那一套。

认证方案

这个讨论了很久,最后选了 HMAC-SHA256 签名认证。为什么不用 API Key 直传或者 OAuth?

  • API Key 太简单了,被抓包直接就泄露了
  • OAuth 对接成本太高,我这就是个扫码服务,不值得搞那么重

签名算法是这样的:

复制代码
签名字符串 = HTTP方法 + "\n" + 请求路径 + "\n" + 时间戳 + "\n" + 请求体
签名 = HMAC-SHA256(签名字符串, appSecret)

请求带上三个 Header 就行:

复制代码
X-App-Key: opk_xxx
X-Timestamp: 1715932800
X-Signature: <签名值>

时间戳超过 5 分钟就拒绝,防止重放攻击。

关于 appSecret 的存储,HMAC 验证需要用到原文,不能单向哈希。所以最后用 AES-256-GCM 加密存储,密钥放环境变量里。

数据库设计

就两张表。

tb_open_app 是应用注册表:

字段 说明
appName 应用名称
appKey 分配的 appKey
appSecretEncrypted AES 加密后的 secret
callbackUrl 回调地址
status 0待审核 1启用 2禁用

tb_qr_session_log 是扫码会话日志:

字段 说明
token 会话 token
appKey 所属应用
status -1未扫码 0已扫码 1已确认
scanData 用户信息 JSON
callbackStatus 回调结果

没用复杂的关联关系,就简单的两张表加上 Redis 缓存。

Redis 的 key 设计

复制代码
op:session:{token}          # 会话数据,1小时过期
op:session:{token}:scan     # 扫码用户信息
op:rate:{appKey}:{日期}      # 每日请求计数

op: 前缀和原有的 screen_key: 区分开,之前代码里还有个坑——存储 key 用了两种分隔符:screen_key:${token}screen_key/${token},排查的时候差点没找到。

回调通知

用户扫码确认之后,我这边会做两件事:

  1. 异步 POST 到注册时填的 callbackUrl
  2. 外部应用也可以轮询 /qr/status 接口

回调也是带签名的,外部应用可以用 appSecret 验证请求确实来自我的平台,防止有人伪造回调。

失败的话会重试 3 次,间隔分别是 1s、2s、4s。

踩坑记录

小程序 code2Session 返回值取错了一层

服务端返回的是:

json 复制代码
{ "status": true, "data": { "openid": "xxx" } }

小程序那边直接 response.data.openid 去取,当然拿到的是 undefined。应该是 response.data.data.openid

这问题排查了挺久,因为小程序端没法方便地打断点。

handleLogin 和 handleAuth 并行执行

小程序的 onLoad 里同时调了 handleLogin(获取 openid)和 handleAuth(展示授权弹窗),两个都是异步的。问题是用户点确认的时候,code2Session 请求还没回来,openid 还是空的。

改成 handleLogin().then(() => handleAuth()) 串行执行就好了。

userInfo 不应该缓存

之前的代码把 wx.getUserProfile 的结果缓存到了 Storage 里。用户换了头像昵称之后,缓存里还是旧的,拿到的就是过期信息。

干掉了缓存逻辑,每次扫码都重新弹窗获取。

环境变量多了一个逗号

env 复制代码
miniAppId="wxf6fafa413396f3bd",

末尾多了个逗号,导致 appid 变成了 wxf6fafa413396f3bd,,微信接口返回 invalid appid。这种问题肉眼根本看不出来。

接口列表

公开接口(无需签名):

  • POST /open-platform/register — 注册应用

小程序端接口(无需签名):

  • GET /open-platform/mini/code2Session — code 换 openid
  • GET /open-platform/mini/info — 获取会话信息
  • POST /open-platform/mini/confirm — 确认授权
  • PUT /open-platform/mini/status — 更新扫码状态

应用接口(需要签名):

  • POST /open-platform/app/info — 查看应用信息
  • POST /open-platform/app/update — 更新应用
  • POST /open-platform/app/regenerate-secret — 重置密钥
  • POST /open-platform/qr/getToken — 创建扫码会话
  • GET /open-platform/qr/getImg — 获取二维码图片
  • GET /open-platform/qr/status — 查询扫码状态

管理员接口:

  • GET /open-platform/admin/apps — 查看所有应用
  • POST /open-platform/admin/approve/:id — 审核通过
  • POST /open-platform/admin/reject/:id — 拒绝

后续规划

目前还是个基础版本,后续可以考虑加上:

  • 速率限制(现在虽然数据库里有 dailyRateLimit 字段,但还没实现拦截逻辑)
  • 回调重试的定时任务
  • 应用数据统计面板
  • Webhook 管理页面

先把核心流程跑通再说吧。

加载评论中...