公众号 H5 与小程序账号打通:网页授权、UnionID 与静默登录的踩坑笔记

2026-09-24 01:25:19 3 次浏览
微信小程序公众号微信开发OAuth踩坑复盘

适用读者:同时维护公众号 H5 与微信小程序、被「同一个用户两个身份」折磨过的后端与全栈工程师。

三月份第二周,运营在群里甩来一个截图:同一位会员在公众号 H5 里显示剩余积分 3800,切到小程序下单一看只剩 620。排查了半天积分流水才发现根本不是扣错了账——这两端压根是 users 表里两条不同的记录,两个 openid,两个 user_id。这事儿暴露的不是某个 bug,而是账号体系从设计之初就没把「同一个人」这件事想清楚。后来花了两周做 openid/unionid 的梳理与数据合并,把路上踩的坑记录下来,希望能帮后来人少走弯路。

一、问题是怎么暴露的:先理解微信的身份模型

很多团队第一次遇到这个问题,反应是「微信是不是有 bug」。其实不是。微信对「用户身份」的认定是以 appid 为单位的:同一个用户在公众号 A 里是一个 openid,在小程序 B 里又是另一个 openid,这两个字符串之间没有任何公开的对应关系。想让两端认出同一个人,只有两条路:要么两端挂在同一个开放平台账号下、用 unionid 做关联;要么自己造一套手机号绑定的映射逻辑。

手机 H5 与小程序握手及身份合并

我们项目早期两种都没做,注册时只在各自端里插入一条 users 记录,出问题是迟早的事。这里先把机制讲透,再说怎么改。

原理与机制剖析:openid、unionid 与 session_key 到底是什么关系

微信的身份体系可以概括成三层:

  • openid:用户在某个具体 appid(公众号、小程序、网站应用)下的身份标识,同一用户在不同 appid 下 openid 不同。它是「端内」的身份。
  • unionid:前提是这几个 appid 绑定在同一个微信开放平台账号下。绑定之后,同一用户在 These appid 下的 unionid 相同,可以作为跨端的全局身份键(Global Identity Key)。
  • session_key:小程序 wx.login 换来的会话密钥,用来在服务端解密手机号等加密数据。它不是登录凭证本身,而且会过期,过期机制微信不承诺任何时长。

一个容易混淆的点:公众号网页授权拿不到 unionid 的场景,比文档里写的多。官方文档只说「已关注公众号时可取到」,但实际还有作用域的影响——snsapi_base 静默授权只返回 openid,不带 unionid;只有 snsapi_userinfo 且满足绑定条件才能拿到。这个细节我们是在第 3 周联调时才确认的。

下面这张时序图是公众号 H5 侧网页授权的完整流程:

sequenceDiagram
    participant U as 用户浏览器
    participant W as 微信客户端
    participant B as 业务后端
    U->>W: 访问 H5 页面(带 redirect_uri)
    W->>U: 未登录则弹出授权页(snsapi_userinfo)
    U->>W: 同意授权
    W->>U: 302 回调 redirect_uri?code=xxx&state=yyy
    U->>B: 携带 code 请求业务接口
    B->>W: 用 code + appid + secret 换 access_token 与 openid
    W->>B: 返回 access_token/openid(已关注则含 unionid)
    B->>W: 拉取用户信息(snsapi_userinfo 才有)
    B->>B: 按 unionid 查找或创建统一用户
    B->>U: 下发自家会话 token

二、unionid 拿不到的几种场景与排查清单

联调期间我们收集到四类「unionid 为空」的情况,按出现频率整理成下表,遇到问题时照着对一遍能省不少白费劲的时间:

场景 现象 根因 处理方式
未绑定开放平台 两端都拿得到 openid,unionid 字段缺失 公众号与小程序不在同一个开放平台账号下,或根本没绑 到开放平台完成绑定,绑定动作即时生效
用了 snsapi_base 网页授权只回 openid 静默授权的作用域不含用户信息 需要跨端身份时改用 snsapi_userinfo
公众号用户未关注且未授权 user info 接口报 40003 非关注用户走 userinfo 拿不到 unionid 引导关注,或降级走手机号绑定
不同主体/不同开放平台账号 绑定了但 unionid 仍不一致 两个 appid 绑在两个开放平台主体上 这类没法用 unionid,只能靠手机号做映射

第四种是最麻烦的,属于结构性问题。我们有个子品牌小程序绑在另一个开放平台账号上,最后只能做「手机号兜底绑定」,留到第六节讲数据表时一起说。

关键结论:unionid 是绑定关系的产物,不是接口的产物。 接口只是把已经存在的关联读出来;绑定没做或绑错主体,怎么调接口都是空。

三、网页授权的域名配置与 code 的生命周期

回调域名的三个坑

网页授权回调域名在公众号后台「网页授权域名」处配置,有三个容易忽略的约束。域名不能带协议头和端口,文件要放到域名根目录下可被公网访问,一个公众号最多只能配置两个域名——第二条很多人栽过,测试环境用内网穿透域名时,校验文件必须真的能被微信的服务器拉到。

我们的做法是让运维在校验文件路由上做一层通配:任何 MP_verify_xxx.txt 形式的请求都直接返回文件内容,省得每次换环境都要重新发版。这件事在第五周做灰度环境时验证过,切换测试域名后五分钟内就能通过校验。

code 只能用一次,且五分钟过期

code 是一次性票据(One-time Ticket),换过一次 access_token 后立即失效,重复消费会报 40163: code been used。我们在压测时踩过一个隐蔽的坑:前端因为超时重试,同一 code 请求了两次后端,后端没做幂等,第二次直接把异常抛给了用户。修法有两个方向:前端拿到 code 后立即消费并从 URL 里清掉;后端对 code 做短时去重(Redis setnx,五分钟 TTL),重复请求返回第一次的结果。

下面是我们后端消费 code 的核心代码(Node.js 18 + Express + ioredis,微信接口封装用的是自研轻量 SDK):

// deps: express@4.18, ioredis@5.3, node >= 18
// 环境:公众号 appid/secret 走 KMS 解密后注入环境变量,禁止硬编码
const express = require("express");
const Redis = require("ioredis");

const app = express();
// Redis 用于 code 幂等去重与会话缓存,生产环境建议集群模式
// key 命名带上业务前缀,避免和缓存模块的 key 冲突
const redis = new Redis(process.env.REDIS_URL);

app.get("/auth/wechat/callback", async (req, res) => {
  const { code, state } = req.query;
  // state 是防 CSRF 的随机串,必须与下发时比对,不能省略
  if (!code || state !== req.cookies.oauth_state) {
    return res.status(400).json({ msg: "invalid oauth state" });
  }

  // 幂等锁:同一个 code 五分钟内只允许消费一次
  // setnx 抢锁失败的请求,说明前面已有请求在处理同一 code
  const lockKey = `wx:code:${code}`;
  const locked = await redis.set(lockKey, "1", "EX", 300, "NX");
  if (!locked) {
    return res.status(409).json({ msg: "code already consumed" });
  }

  try {
    // 换取网页授权 token,注意这里是 OAuth 专用 token,和全局 access_token 不是一回事
    // sns/oauth2 这组接口走的是用户级授权,/cgi-bin 下那组才是全局票据
    const url = `https://api.weixin.qq.com/sns/oauth2/access_token`;
    const resp = await fetch(`${url}?appid=${process.env.WX_APPID}` +
      `&secret=${process.env.WX_SECRET}&code=${code}&grant_type=authorization_code`);
    const data = await resp.json();
    // errcode 40029 表示 code 无效,40163 表示 code 已被使用
    if (data.errcode) {
      return res.status(502).json({ msg: `wechat error ${data.errcode}` });
    }

    // openid 是端内身份;unionid 只有绑定开放平台后才可能出现
    // 判断用户是否已关注公众号,可再调 user/info 接口订阅位 subscribe 字段
    // 这里统一交给账号模块做「查找或创建」,后面第六节展开
    const user = await account.resolveOrCreate({
      openid: data.openid,
      unionid: data.unionid || null,
      channel: "mp_h5",  // 标记来源端,方便后续对账
    });

    // 下发自家会话 token,微信侧的 access_token 不要透传给前端
    const token = await session.issue(user.id, { channel: "mp_h5" });
    res.json({ token });
  } catch (err) {
    // 消费失败时释放锁,给重试留一条生路;注意仅在网络类错误时释放
    await redis.del(lockKey);
    throw err;
  }
});

这段代码里最值得强调的是幂等锁的释放时机:只有网络类异常才释放,微信明确返回「code 已使用」时不能释放,否则重试风暴会把日志刷爆。

四、小程序侧:wx.login 与 code2session

小程序的登录模型和网页授权不同,它是「静默为主、按需取信息」。wx.login 不弹任何框,直接返回一个 code,服务端拿 code 调 code2session 接口,换回 openid、session_key,满足条件时还有 unionid。

flowchart LR
    A[小程序启动] --> B[wx.login 获取 code]
    B --> C[服务端 code2session]
    C --> D{unionid 存在?}
    D -- 是 --> E[按 unionid 关联统一账号]
    D -- 否 --> F[按 openid 建立端内映射]
    F --> G{需要手机号?}
    G -- 是 --> H[button open-type=getPhoneNumber]
    H --> I[解密后作为绑定兜底]
    G -- 否 --> J[仅匿名身份使用]
    E --> K[下发会话 token]
    I --> K
    J --> K

有个反直觉的事实:code2session 返回 unionid 的条件比网页授权更苛刻。只有在「该用户已关注同主体的公众号」或「该用户曾经授权过同主体的其他应用」等场景下才会返回;冷启动的纯新用户在小程序端经常拿不到 unionid。所以服务端代码必须把 unionid 当成可空字段处理,不能当成必然存在的关联键。

服务端处理逻辑(Python 3.11 + FastAPI + httpx):

# deps: fastapi>=0.110, httpx>=0.27, python 3.11
# 环境:小程序 secret 从配置中心读取,code2session 域名需在后台加白
import httpx
from fastapi import HTTPException

CODE2SESSION = "https://api.weixin.qq.com/sns/jscode2session"

async def code2session(js_code: str) -> dict:
    """用 wx.login 的 code 换取会话信息。"""
    params = {
        # appid 与 secret 必须是小程序自己的,混用公众号的会报 40013
        "appid": settings.WEAPP_APPID,
        "secret": settings.WEAPP_SECRET,
        "js_code": js_code,
        # 固定值,写死即可
        "grant_type": "authorization_code",
    }
    async with httpx.AsyncClient(timeout=5) as client:
        # 微信接口偶发超时,务必设置短超时并做一次重试
        resp = await client.get(CODE2SESSION, params=params)
        data = resp.json()

    # errcode 为 0 或不出现都算成功;-1 是微信系统繁忙,可重试
    # 网络超时单独 catch 后重试一次,这里为了篇幅没展开
    if data.get("errcode") not in (0, None):
        # 40029: code 无效; 45011: 频率限制; 40226: 高风险用户
        raise HTTPException(502, f"code2session failed: {data}")

    # session_key 绝不能下发到前端,只能留在服务端用于解密
    # 落库或进 Redis 时建议加密存储,泄漏后可被用来伪造解密结果
    # unionid 可能缺失,必须按可空处理
    return {
        "openid": data["openid"],
        "unionid": data.get("unionid"),
        "session_key": data["session_key"],
    }

五、session_key 过期与静默重登的工程化处理

session_key 的有效期微信不保证,官方口径是「用户可能主动触发使其失效」。依赖它做解密(比如手机号密文)的团队必须接受一个现实:任何时候解密失败,都要有重登兜底

我们的失败率曲线有代表性:上线首周解密失败率 3.2%,加了静默重登后稳定到 0.4% 以下,剩下的部分是用户主动点了「退出登录」再触发解密的边缘场景。做法是在解密失败、且错误码符合「session_key 失效」特征时,向前端下发一个特定状态码,前端静默重跑一遍 wx.login 流程,全程用户无感知。不要用 checkSession 做预检再加逻辑分支——直接把失败当成重登信号更省事,少一次往返。

注意区分两种「过期」:网页授权的 access_token 有效期两小时,可以配 refresh_token 续期 thirty 天;小程序侧根本没有 refresh 概念,token 失效就是重跑登录。两套机制不要混着设计。

六、账号合并迁移的数据表设计

回到开头那条 3800 与 620 的积分差。修复它分两件事:表结构改造让未来不再分裂,存量数据合并把历史对齐。表结构上核心思路是「统一用户表 + 多端身份映射表」,身份映射一对多挂在统一用户下:

维度 改造前 改造后
用户表 users 直接含 openid 字段,H5/小程序各一张表 统一 user 表,不含任何 openid
身份关联 无,openid 即用户 identity_map 表存 openid/unionid 与 user_id 的映射
跨端识别 不支持,两端各一个账号 unionid 优先,手机号兜底
积分与订单 分散在两套表 统一挂 user_id,合并时迁移

对应的建表语句(MySQL 8.0,InnoDB):

-- 统一用户表:只存业务属性,不含任何微信身份字段
CREATE TABLE user (
  id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  nickname    VARCHAR(64) NULL COMMENT '展示用昵称,可为空',
  mobile      VARCHAR(20) NULL COMMENT '手机号,作为跨端兜底绑定键',
  created_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  -- 手机号要加唯一索引?不行,历史脏数据可能重复,先普通索引再清洗
  KEY idx_mobile (mobile)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 身份映射表:一个 user 可以挂多个端内身份
-- 同一 unionid 下两个 openid 都会落到这张表,查身份先走这张表
CREATE TABLE identity_map (
  id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  user_id     BIGINT UNSIGNED NOT NULL COMMENT '指向统一用户',
  appid       VARCHAR(32) NOT NULL COMMENT '公众号或小程序的 appid',
  openid      VARCHAR(64) NOT NULL COMMENT '端内身份标识',
  unionid     VARCHAR(64) NULL COMMENT '全局身份,绑定开放平台后才有',
  channel     VARCHAR(16) NOT NULL COMMENT 'mp_h5 / weapp 等来源标记',
  created_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  -- openid 按 appid 全局不重复,联合唯一索引防止重复插入
  UNIQUE KEY uk_appid_openid (appid, openid),
  KEY idx_unionid (unionid),
  KEY idx_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 合并历史账号:按 unionid 把小程序端的身份挂到既有用户下
-- 迁移务必在事务里做,身份与积分要么都迁要么都不迁
-- 存量合并:小程序老表 weapp_users 里的账号向上挂载到统一用户
-- 合并前先跑 dry-run 统计配对数量,确认无误再真正执行写入
START TRANSACTION;
INSERT INTO identity_map (user_id, appid, openid, unionid, channel)
SELECT u.id, 'wx_weapp_xxx', w.openid, w.unionid, 'weapp'
FROM weapp_users w
JOIN mp_users u ON u.unionid = w.unionid   -- 以公众号侧用户为主账号
WHERE w.unionid IS NOT NULL;
COMMIT;

存量合并的实际操作顺序是:先跑 dry-run 脚本统计有多少组 unionid 能配上对(我们是 41,700 组)、多少组配不上(约 2,300 组,走手机号兜底或放弃合并),再在小范围灰度用户上验证积分合计正确,最后才全量跑。合并时积分取两端之和、昵称以最近活跃端为准,这些规则要提前和业务方白纸黑字定下来。

七、误区澄清与收尾

几个流传较广的误解值得澄清。有人说「openid 加密后可以当跨端标识用」——不行,不同 appid 下 openid 本身就不同,加密改变不了它不是同一个人的事实。也有人说「绑定了开放平台就万事大吉」——绑定只保证 unionid 一致,冷用户拿不到 unionid、多开放平台主体这些结构性障碍依然存在。还有团队把 session_key 存进前端缓存,这属于安全问题而非踩坑问题,session_key 只能留在服务端。

趋势上,微信在逐步收敛多端身份的获取路径,getPhoneNumber 这类组件让手机号绑定变得越来越顺滑,把它设计成兜底绑定键而不是仅有的关联手段,是当前比较稳妥的架构选择。身份体系这类底层设计,返工成本远高于多花两天前期设计,值得在一开始就想清楚。

如果你也在做双端账号打通,欢迎在评论区聊聊你遇到的 unionid 怪现象,尤其是多开放平台主体的合并方案。

参考与延伸

  • 微信网页授权官方文档:https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/Wechat_webpage_authorization.html
  • 小程序 code2session 接口文档:https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-login/code2Session.html
  • UnionID 机制说明:https://developers.weixin.qq.com/doc/offiaccount/User_Management/Get_users_basic_information_UnionID.html
  • 小程序登录流程说明:https://developers.weixin.qq.com/miniprogram/dev/framework/app-service/app.html

微信小程序|公众号开发|网页授权|unionid|openid|静默登录|code2session|账号体系

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。