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 │ │
- 微信登录:小程序调用
wx.login()拿到临时code,发给后端。 - 换取身份:后端用
code调微信jscode2session,换到openId(小程序内用户唯一标识)与unionId(开放平台主体下唯一,可能为空),据此建立/刷新本地绑定记录,签发一个自有会话 token。 - 授权手机号:用户点击
open-type="getPhoneNumber"按钮,微信回调返回一个动态令牌code(新版手机号快速验证)。 - 换取手机号:后端用小程序全局
access_token调getuserphonenumber,用code换到真实手机号。 - 解析账号:后端按手机号解析对应的 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 }
needBindPhone 为 true 时,小程序进入「授权手机号」步骤。
4.2 getPhoneNumber → 手机号
新版手机号快速验证:getPhoneNumber 按钮回调返回一个 code,后端用小程序全局 access_token 换取手机号。
access_token 用 stable_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
}
务必做手机号精确比对:listUsers 的 keywords 可能命中昵称、邮箱等其它字段,因此不能直接取第一个结果,必须逐个校验手机号(兼容 +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_token用stable_token模式缓存在 Redis,避免频繁请求触发限流。
9. 本地联调(无微信 / Authing 凭据)
设 MINIAPP_MOCK=true 后:
code2session用code派生一个稳定的 mockopenId;getPhoneNumber返回固定手机号13800000000;resolveAuthingUserIdByPhone返回确定性的假账号 ID(mock_authing_<phone>)。
这样无需真实微信密钥即可在微信开发者工具里把「登录 → 授权手机号 → 关联账号」整条链路走通,方便先联调前端。配好真实 WX_MINIAPP_SECRET 后再关闭 mock,用真机或体验版验证。