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 │                               │
  1. 微信登录:小程序调用 wx.login() 拿到临时 code
  2. 授权手机号:用户点击 open-type="getPhoneNumber" 按钮,微信回调返回一个动态令牌 code(新版手机号快速验证),作为 phoneCode 一并提交。
  3. 换取身份:账号中心用 code 调微信 jscode2session 换到 openId(小程序内唯一)与 unionId(开放平台主体下唯一,小程序未绑定开放平台时为空);用小程序全局 access_tokengetuserphonenumberphoneCode 换成真实手机号。
  4. 身份解析:把 (openId, unionId, phone) 交给统一的身份解析链路,决定这条身份属于哪个账号(见下一节)。
  5. 签发会话:签发与网页端同型的 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),老用户走这条
2union_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_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 换手机号,并统一规范化成 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)

providerprovider_uid说明
wechat_miniappopenIdunion_id 列存 UnionID,用于微信家族归一
phoneE.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-phoneBearer{ code },为当前账号补绑手机号
GET/api/miniapp/profileBearer当前用户资料(手机号做掩码处理)
GET/api/miniapp/handoffBearerPC 端引导信息(二维码内容 / 插件安装文档 / 注册地址)
GET/api/miniapp/browser-sessionBearer检测 PC Chrome 插件连接状态
GET/api/miniapp/shopping/sitesBearer购物表单可选网站列表
POST/api/miniapp/shopping/taskBearer提交购物任务,返回 { taskId }
GET/api/miniapp/shopping/taskBearer轮询任务结果(思考过程 + 最终报告)
GET/api/miniapp/shopping/snapshotBearer代理任务浏览器截图,供 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_tokenstable_token 模式缓存在 Redis,避免频繁请求触发限流。
  • 缺手机号时不要自行建号:见第 1 节末尾,这是防止一人两号的关键。

10. 本地联调(无微信凭据)

MINIAPP_MOCK=true 后:

  • code2sessioncode 派生一个稳定的 mock openIdmock_openid_<hex>),便于反复联调同一个「用户」;
  • getPhoneNumber 返回固定手机号 13800000000
  • 购物相关接口返回 mock 任务与 sk-mock-miniapp

身份解析、建号、签发 token 走的都是真实链路,因此无需真实微信密钥即可在微信开发者工具里把「登录 → 授权手机号 → 并入账号 → 提交购物任务」整条链路走通。配好真实 WX_MINIAPP_SECRET 后再关闭 mock,用真机或体验版验证。