WeChat Mini Program Phone Account Binding Guide

本文面向需要理解或复用「微信小程序用手机号关联 InfiniSynapse(Authing) 账号」机制的开发者/集成者。它讲清楚一个只承担「账号入口 + 引导中转」的微信小程序,如何在服务端把微信用户与已有的 InfiniSynapse(Authing) 账号打通,从而复用同一套账号体系。

适用场景:AI 购物(省钱比价助手)小程序。真正的 AI 能力(比价、读差评、加购物车)运行在 PC 端 Chrome 的 InfiniSynapse 浏览器插件里,手机端无法执行;小程序只负责登录、拿手机号、关联账号,并引导用户去 PC 端。

1. 它是怎么工作的

整个关联过程分两次请求:先 wx.login 建立会话,再用 getPhoneNumber 授权手机号并在服务端解析出 Authing 账号。

小程序                          你的后端                        微信开放平台            Authing
  │                               │                               │                    │
  │ ① wx.login() 拿 code ─────────▶                               │                    │
  │                               │ ② code2session(code) ────────▶│                    │
  │                               │ ◀── openId / unionId ─────────│                    │
  │ ◀── 会话 token(bound?/phone?) │                               │                    │
  │                               │                               │                    │
  │ ③ getPhoneNumber 按钮 → code ─▶                               │                    │
  │                               │ ④ getuserphonenumber(code) ──▶│                    │
  │                               │ ◀──────── phoneNumber ────────│                    │
  │                               │ ⑤ 按手机号解析账号 ──────────────────────────────▶│
  │                               │ ◀──────────── authingUserId / 未找到 ─────────────│
  │ ◀── 新会话 token + bound/needRegister                          │                    │
  1. 微信登录:小程序调用 wx.login() 拿到临时 code,发给后端。
  2. 换取身份:后端用 code 调微信 jscode2session,换到 openId(小程序内用户唯一标识)与 unionId(开放平台主体下唯一,可能为空),据此建立/刷新本地绑定记录,签发一个自有会话 token。
  3. 授权手机号:用户点击 open-type="getPhoneNumber" 按钮,微信回调返回一个动态令牌 code(新版手机号快速验证)。
  4. 换取手机号:后端用小程序全局 access_tokengetuserphonenumber,用 code 换到真实手机号。
  5. 解析账号:后端按手机号解析对应的 Authing 用户 ID(authingUserId)。匹配到则标记 bound=true;未匹配到则返回 needRegister,引导用户去 PC 端用同一手机号注册/登录。

这套模式的关键:手机号是小程序用户与 InfiniSynapse 账号之间的唯一纽带。只要用户在 PC 端用同一个手机号注册了 InfiniSynapse 账号,小程序侧就能自动关联,无需重复注册。

2. 准备工作

环境变量

# 微信小程序凭据(小程序后台 → 开发管理 → 开发设置)
WX_MINIAPP_APPID=wxb2593c4dd46cf539
WX_MINIAPP_SECRET=你的小程序密钥

# 无凭据时本地联调:开启后用 mock 数据替代真实微信 / Authing 接口
MINIAPP_MOCK=false

# 未匹配到账号时引导注册的地址(有默认值)
MINIAPP_REGISTER_URL=https://infinisynapse.cn

# 复用平台已有:JWT_SECRET、AUTHING_*、REDIS_*
  • JWT_SECRET:用于签发/校验小程序自有会话 token。
  • AUTHING_*:Authing 管理端凭据,用于按手机号查询用户。
  • REDIS_*:缓存微信全局 access_token

依赖

  • 一个能发 HTTP 请求的服务端;
  • 一份持久化存储(示例用 MongoDB)保存小程序用户 ↔ 账号绑定关系;
  • Authing 管理端 SDK(用于按手机号查询账号)。

3. 会话 token

后端用 wx.login 换到的 openId 建立自有会话,签发一个 JWT 作为后续接口的凭证(Authorization: Bearer <token>)。载荷示例:

interface MiniappSessionPayload {
  typ: 'miniapp'
  openId: string
  unionId?: string
  phone?: string
  authingUserId?: string
  bound?: boolean
}

手机号绑定成功后会重新签发携带最新 phone / authingUserId / bound 的 token,前端替换旧 token 即可。

4. 关键步骤实现

4.1 wx.login → openId

jscode2session 把临时 code 换成 openId / unionId / session_key

const url =
  `https://api.weixin.qq.com/sns/jscode2session?appid=${appId}` +
  `&secret=${appSecret}&js_code=${code}&grant_type=authorization_code`
const data = await (await fetch(url)).json()
// data.openid / data.unionid / data.session_key

拿到 openId 后 upsert 一条绑定记录(首次登录写入空的 phone / authingUserId),并签发会话 token。返回给前端:

{ "token": "...", "bound": false, "needBindPhone": true, "account": null }

needBindPhonetrue 时,小程序进入「授权手机号」步骤。

4.2 getPhoneNumber → 手机号

新版手机号快速验证:getPhoneNumber 按钮回调返回一个 code,后端用小程序全局 access_token 换取手机号。

access_tokenstable_token 模式获取并缓存在 Redis(提前 5 分钟过期,避免边界):

// 缓存命中直接用,否则请求 stable_token 并 setex 写回 Redis
const res = await fetch('https://api.weixin.qq.com/cgi-bin/stable_token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ grant_type: 'client_credential', appid, secret }),
})

再用 access_token + code 换手机号:

const res = await fetch(
  `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${accessToken}`,
  { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) },
)
const data = await res.json()
const phone = data.phone_info?.purePhoneNumber || data.phone_info?.phoneNumber

第一版只实现新版 code 模式。旧版 encryptedData / iv 可从前端一并透传到后端,但暂未解密。

4.3 手机号 → Authing 账号

这是关联的核心。解析顺序为「本地绑定优先 + Authing 管理端 best-effort 查询」:

async function resolveAuthingUserIdByPhone(phone: string): Promise<string | null> {
  // 1. 本地绑定表已存在该手机号且已关联账号 → 直接复用(这些数据是此前精确匹配后写入的)
  const existing = await bindingModel.findOne({ phone, authingUserId: { $ne: '' } }).lean()
  if (existing?.authingUserId) return existing.authingUserId

  // 2. Authing 管理端按手机号查询
  try {
    const mc = authService.getManagementClient()
    const res = await mc.listUsers({
      options: { pagination: { page: 1, limit: 50 } },
      keywords: phone, // 注意:listUsers 不接受 { phone },只能用 keywords 搜索
    })
    const list = res?.data?.list ?? []
    const matched = list.find(u => phoneMatches(u, phone))
    if (matched) return String(matched.userId || matched.id)
  } catch (e) {
    // 查询失败按「未找到」处理,绝不误绑
  }
  return null
}

务必做手机号精确比对listUserskeywords 可能命中昵称、邮箱等其它字段,因此不能直接取第一个结果,必须逐个校验手机号(兼容 +86 前缀):

function phoneMatches(user: any, phone: string): boolean {
  const raw = user?.phone || user?.phoneNumber || user?.phone_number
  if (!raw) return false
  const normalized = String(raw).replace(/^\+?86/, '').trim()
  return normalized === phone || String(raw).trim() === phone
}

找不到则返回 null,上层据此把 needRegister 置为 true,引导用户去 PC 端注册。

4.4 写回绑定 + 重签会话

const phone = await getPhoneNumber(dto)
const authingUserId = await resolveAuthingUserIdByPhone(phone)
const bound = !!authingUserId

await bindingModel.findOneAndUpdate(
  { openId: session.openId },
  { $set: { phone, authingUserId: authingUserId || '', bound } },
  { new: true },
)

// 携带最新信息重新签发会话 token
const token = signSession({ typ: 'miniapp', openId: session.openId, phone, authingUserId, bound })
return { token, bound, needRegister: !bound, registerUrl: !bound ? registerUrl : undefined }

5. 数据模型

一条绑定记录代表「一个小程序用户(openId)」↔「InfiniSynapse(Authing) 账号」的关系:

字段说明
openId微信小程序内用户唯一标识(同一 appId 下唯一),主键语义
unionId微信开放平台主体下唯一,可选
phone微信授权获取的手机号,用于关联账号
authingUserId关联到的 Authing 用户 ID(sub),为空表示尚未关联
apiKey为该账号签发的购物服务 API Key(sk-xxx),见第 7 节
bound是否已完成账号关联

6. 接口一览(全局前缀 /api

方法路径说明
POST/api/miniapp/login{ code } 换会话,返回 { token, bound, needBindPhone, account }
POST/api/miniapp/bind-phone携带 Bearer token + { code },换手机号并关联账号,返回 { token, bound, needRegister, registerUrl }
GET/api/miniapp/profile当前用户资料(手机号做掩码处理)

7. 关联之后:服务端签发 API Key

关联到 authingUserId 之后,小程序侧就能以该账号身份调用 InfiniSynapse 的能力,而无需拿到用户的 access token:后端用 API Key 服务直接为该 authingUserId 服务端签发一个 sk- 开头的 API Key,缓存在绑定记录的 apiKey 字段上,用于调用 app 服务(如 app.infinisynapse.cn)。

async function ensureApiKey(binding): Promise<string> {
  if (binding.apiKey) {
    const userId = await apiKeyService.getUserIdByApiKey(binding.apiKey).catch(() => undefined)
    if (userId === binding.authingUserId) return binding.apiKey // 仍有效则复用
  }
  const created = await apiKeyService.createApiKey({ userId: binding.authingUserId, name: '购物小程序' })
  binding.apiKey = created.apiKey
  await binding.save()
  return created.apiKey
}

8. 关键约束与坑

  • 账号身份必须三方一致:插件登录账号、API Key 的 userId、按手机号解析出的 authingUserId,三者必须是同一个 Authing sub。生产环境 AUTH_TYPE=authing 时三者天然对齐;本地 AUTH_TYPE=jwt 下不对齐,联调请用真实 Authing 账号或开启 MINIAPP_MOCK=true 走 mock 数据。
  • 手机号必须精确比对:Authing listUsers 只能按 keywords 模糊搜索,务必在结果里逐个校验手机号,绝不返回 phone 不匹配的用户。
  • SDK 方法签名差异:不同版本的 authing-node-sdk 的 listUsers 签名不同,接真实 Authing 后按实际方法收敛。
  • 只实现新版 getPhoneNumber:旧版 encryptedData / iv 未解密。
  • access_token 缓存:微信全局 access_tokenstable_token 模式缓存在 Redis,避免频繁请求触发限流。

9. 本地联调(无微信 / Authing 凭据)

MINIAPP_MOCK=true 后:

  • code2sessioncode 派生一个稳定的 mock openId
  • getPhoneNumber 返回固定手机号 13800000000
  • resolveAuthingUserIdByPhone 返回确定性的假账号 ID(mock_authing_<phone>)。

这样无需真实微信密钥即可在微信开发者工具里把「登录 → 授权手机号 → 关联账号」整条链路走通,方便先联调前端。配好真实 WX_MINIAPP_SECRET 后再关闭 mock,用真机或体验版验证。

WeChat Mini Program Phone Account Binding Guide | InfiniSynapse