WeChat Mini Program Phone Account Binding Guide
本文面向需要理解或复用「微信小程序用手机号并入 InfiniSynapse 账号」机制的开发者/集成者。它讲清楚一个只承担「账号入口 + 引导中转」的微信小程序,如何在服务端把微信用户并入已有的 InfiniSynapse 账号,从而复用同一套账号体系。
适用场景:AI 购物(省钱比价助手)小程序。真正的 AI 能力(比价、读差评、加购物车)运行在 PC 端 Chrome 的 InfiniSynapse 浏览器插件里,手机端无法执行;小程序只负责登录、拿手机号、并入账号,并引导用户去 PC 端。
账号体系说明:InfiniSynapse 现在使用自建认证(账号中心即
infini-proxy服务,RS256 JWT),不再依赖任何第三方用户池。小程序登录拿到的就是与网页端完全相同的 access / refresh token,没有单独的「小程序会话」。
1. 它是怎么工作的
关键差异先说清楚:没有「小程序账号」这种东西。小程序登录只是账号中心的一种登录方式(provider wechat_miniapp),产出的是平台统一的 access token。
理想情况下整个流程只需一次请求——前端在登录页直接用 getPhoneNumber 按钮把 wx.login 的 code 和手机号授权 code 一起交上来:
小程序 账号中心 微信开放平台
│ │ │
│ ① wx.login() 拿 code │ │
│ ② getPhoneNumber 拿 phoneCode │ │
│ ③ POST /miniapp/login ───────▶│ │
│ { code, phoneCode } │ ④ code2session(code) ────────▶│
│ │ ◀── openId / unionId ─────────│
│ │ ⑤ getuserphonenumber ────────▶│
│ │ ◀──────── phoneNumber ────────│
│ │ ⑥ 身份解析:openid → unionId │
│ │ → 手机号 → 建号 │
│ ◀── accessToken / refreshToken │ │
- 微信登录:小程序调用
wx.login()拿到临时code。 - 授权手机号:用户点击
open-type="getPhoneNumber"按钮,微信回调返回一个动态令牌code(新版手机号快速验证),作为phoneCode一并提交。 - 换取身份:账号中心用
code调微信jscode2session换到openId(小程序内唯一)与unionId(开放平台主体下唯一,小程序未绑定开放平台时为空);用小程序全局access_token调getuserphonenumber把phoneCode换成真实手机号。 - 身份解析:把
(openId, unionId, phone)交给统一的身份解析链路,决定这条身份属于哪个账号(见下一节)。 - 签发会话:签发与网页端同型的 access / refresh token。
手机号仍然是小程序用户与已有账号之间的主要纽带:用户在 PC 端用同一手机号注册过,这里就直接并入那个账号,看到的是同一份数据,无需重复注册。
缺手机号时为什么不建号
如果前端只交了 code 而没交 phoneCode:
- 该 openid 已认识 → 正常登录;
- 该 openid 不认识 → 返回
{ needBindPhone: true },不发 token、不建账号,前端需引导用户授权手机号后重试(重试要重新wx.login,code 是一次性的)。
这一步是刻意的。wx.login 只能拿到 openid,此时建号的话,用户随后授权手机号时才发现该号早有账号,就变成了两个账号,只能靠事后合并加业务数据迁移来补救——那是比多一次交互昂贵得多的代价。
2. 身份解析:这条身份属于谁
小程序不自己判断账号归属。所有登录方式(手机号验证码、微信扫码、公众号、小程序、支付宝、邮箱、GitHub、Google)共用同一个解析器,匹配顺序从强到弱:
| 顺序 | 依据 | 说明 |
|---|---|---|
| 1 | (provider, provider_uid) 精确命中 | 即 ('wechat_miniapp', openId),老用户走这条 |
| 2 | union_id | 仅在微信家族(wechat_*)内归一,让小程序 / PC 扫码 / 公众号是同一个账号 |
| 3 | 已验证手机号 | ('phone', 手机号) 且 verified=1,这是并入 PC 端已有账号的路径 |
| 4 | 已验证邮箱 | 小程序场景用不到 |
| 5 | 以上都没命中 | 新建账号,同时把 openid 与手机号登记为两条身份 |
两条重要约束:
- 只有第三方已验证过的手机号/邮箱才参与合并。
getPhoneNumber拿到的号码是微信验证过的,可以参与;用户自己在表单里填的绝不能——否则填别人的手机号就能并进别人的账号。 - 小程序必须绑定微信开放平台,否则微信不返回
unionid,同一个人从小程序和 PC 扫码进来会是两个账号(除非两边手机号相同,靠第 3 条兜住)。服务端遇到这种情况会打 warn 日志。
3. 准备工作
环境变量
# 微信小程序凭据(小程序后台 → 开发管理 → 开发设置)
WX_MINIAPP_APPID=wxb2593c4dd46cf539
WX_MINIAPP_SECRET=你的小程序密钥
# 登录方式白名单,必须包含 wechat_miniapp(默认值已包含)
AUTH_PROVIDERS=phone,wechat_open,wechat_mp,wechat_miniapp,alipay,email_code,email_password,github,google,username_password
# 无凭据时本地联调:开启后用 mock 数据替代真实微信接口
MINIAPP_MOCK=false
# 引导用户去 PC 端的几个地址(均有默认值)
MINIAPP_REGISTER_URL=https://infinisynapse.cn
MINIAPP_PC_SHOPPING_URL=https://infinisynapse.cn/apps/straight-man-shopping
MINIAPP_PLUGIN_DOC_URL=https://infinisynapse.cn/en/docs/Chrome%20Plugin%20Install
# 购物 agent 所在的 app 服务
MINIAPP_APP_SERVICE_URL=https://app.infinisynapse.cn
MINIAPP_AGENT_LANG=zh_CN
# 复用账号中心已有配置:AUTH_ISSUER、JWT_PRIVATE_KEYS_B64、REDIS_*、MySQL 连接
AUTH_ISSUER/JWT_PRIVATE_KEYS_B64:账号中心签发 access token 所需,与其它登录方式共用,不是小程序专有配置。REDIS_*:缓存微信全局access_token。
依赖
- 账号中心服务(
infini-proxy),MySQL + Redis; - 不需要任何第三方用户池 SDK——用户就在本地
users/user_identities表里。
4. 会话:用平台 token,不要另发一套
改造前小程序自己签一个 typ:'miniapp' 的 JWT,并手写 requireSession 校验。现在这套并行机制已经删除:小程序携带的就是普通 access token,由全局 Guard 校验。
Authorization: Bearer <accessToken>
好处很直接:少一套并行的会话机制,也就少一处需要单独修补的失效、撤销与过期逻辑。access token 过期时用 refresh token 走账号中心统一的刷新端点即可。
5. 服务端关键实现
5.1 wx.login → openId / unionId
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
5.2 phoneCode → 手机号
新版手机号快速验证: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 换手机号,并统一规范化成 E.164(+8613800000000),否则同一个号码写成两种格式,唯一索引形同虚设、按手机号合并也会漏:
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 raw = data.phone_info?.purePhoneNumber || data.phone_info?.phoneNumber
const phone = normalizePhone(raw, 'CN')
只实现新版
code模式。旧版encryptedData/iv未解密。
5.3 交给统一链路
provider 只负责证明「这个 openid(以及可选的手机号)是真的」,产出一条 VerifiedIdentity,剩下的交给 AuthFlowService:
// 带手机号:一次性完成,手机号参与账号合并
const result = await flow.login('wechat_miniapp', { code, phoneCode }, ctx)
// 不带手机号:只认既有账号,认不出返回 null 而不建号
const existing = await flow.loginIfExists('wechat_miniapp', { code }, ctx)
if (existing === null) return { needBindPhone: true }
5.4 登录后补绑手机号
登录时没授权、之后才补的情况走 POST /miniapp/bind-phone。它与登录带 phoneCode 的区别很关键:登录路径上手机号参与账号合并(能并进既有账号),而这里账号已经确定,手机号只能追加给它。因此若该号码已属于别人,只能报冲突——把两个已有数据的账号合并不是绑定接口该做的事。
6. 数据模型
身份归属由 user_identities 决定,一条身份是 (provider, provider_uid):
| provider | provider_uid | 说明 |
|---|---|---|
wechat_miniapp | openId | union_id 列存 UnionID,用于微信家族归一 |
phone | E.164 手机号 | verified=1 才参与合并 |
miniapp_bindings 表已退化为购物业务的附属记录,只存 API Key 缓存与登录统计,身份判定不依赖它,写失败也不影响登录:
| 字段 | 说明 |
|---|---|
open_id | 微信小程序内用户唯一标识,唯一索引 |
union_id | 微信开放平台主体下唯一,可选 |
phone | 冗余记录,可为空 |
authing_user_id | 列名是历史遗留,存的就是本地 users.id。迁移时直接沿用了原用户池的 sub 作为新 user_id,两者值域一致,列里的值从未变过;改名要连带动 DDL 与四端读写,收益只是名字好看 |
api_key | 为该账号签发的购物服务 API Key(sk-xxx),见第 8 节 |
login_count / first_login_at / last_login_at | 登录统计 |
7. 接口一览(全局前缀 /api)
| 方法 | 路径 | 鉴权 | 说明 |
|---|---|---|---|
| POST | /api/miniapp/login | 公开 | { code, phoneCode? },返回 { accessToken, refreshToken, expiresIn, registered, user },或 { needBindPhone: true } |
| POST | /api/miniapp/bind-phone | Bearer | { code },为当前账号补绑手机号 |
| GET | /api/miniapp/profile | Bearer | 当前用户资料(手机号做掩码处理) |
| GET | /api/miniapp/handoff | Bearer | PC 端引导信息(二维码内容 / 插件安装文档 / 注册地址) |
| GET | /api/miniapp/browser-session | Bearer | 检测 PC Chrome 插件连接状态 |
| GET | /api/miniapp/shopping/sites | Bearer | 购物表单可选网站列表 |
| POST | /api/miniapp/shopping/task | Bearer | 提交购物任务,返回 { taskId } |
| GET | /api/miniapp/shopping/task | Bearer | 轮询任务结果(思考过程 + 最终报告) |
| GET | /api/miniapp/shopping/snapshot | Bearer | 代理任务浏览器截图,供 wx.downloadFile 拉取 |
8. 并入之后:服务端签发 API Key
拿到 userId 之后,小程序侧就能以该账号身份调用 InfiniSynapse 的购物能力,而无需拿到用户在 app 服务的 access token:账号中心用 API Key 服务直接为该 userId 服务端签发一个 sk- 开头的 API Key,缓存在绑定记录上,用于调用 app 服务(如 app.infinisynapse.cn)。
这把 key 挂在 userId 上而不是 openid 上——同一个人从小程序和 PC 进来是同一个账号,没有理由给两把 key。
async function ensureApiKey(userId: string): Promise<string> {
const binding = await bindingRepo.findOne({ where: { userId } })
if (binding?.apiKey) {
// 确认仍然有效(未被用户在控制台删掉)
const owner = await apiKeyService.getUserIdByApiKey(binding.apiKey).catch(() => undefined)
if (owner === userId) return binding.apiKey
}
const created = await apiKeyService.createApiKey({ userId, name: '购物小程序' })
if (binding) await bindingRepo.update({ id: binding.id }, { apiKey: created.apiKey })
return created.apiKey
}
9. 关键约束与坑
- 账号身份天然一致:插件登录的账号、API Key 的 userId、小程序并入的账号现在都是同一个
users.id。改造前需要靠外部用户池的 sub 对齐,本地开发环境还会错位;现在全站只有一套账号,这类问题消失了。 - 手机号必须规范化后再比对:写入与查询都走同一套 E.164 规范化,否则
13800000000与+8613800000000会被当成两个号码。 - 小程序要绑定开放平台:不然拿不到
unionid,微信家族内无法归一。 - 只实现新版 getPhoneNumber:旧版
encryptedData/iv未解密。 - access_token 缓存:微信全局
access_token用stable_token模式缓存在 Redis,避免频繁请求触发限流。 - 缺手机号时不要自行建号:见第 1 节末尾,这是防止一人两号的关键。
10. 本地联调(无微信凭据)
设 MINIAPP_MOCK=true 后:
code2session用code派生一个稳定的 mockopenId(mock_openid_<hex>),便于反复联调同一个「用户」;getPhoneNumber返回固定手机号13800000000;- 购物相关接口返回 mock 任务与
sk-mock-miniapp。
身份解析、建号、签发 token 走的都是真实链路,因此无需真实微信密钥即可在微信开发者工具里把「登录 → 授权手机号 → 并入账号 → 提交购物任务」整条链路走通。配好真实 WX_MINIAPP_SECRET 后再关闭 mock,用真机或体验版验证。