7871 字
约 26 分钟
3
微信公众号扫码登录是怎么实现的?一篇讲懂 SSE、半长连接与验证码映射

微信公众号扫码登录是怎么实现的?一篇讲懂 SSE、半长连接与验证码映射

很多网站都有“微信扫码登录”。

用户在电脑上打开二维码,用微信扫一扫,随后网页自动完成登录。

从用户角度看,这个过程非常简单:

打开登录页面
    ↓
微信扫码
    ↓
完成验证
    ↓
网页自动登录

但站在后端角度,会发现这里有一个非常关键的问题:

用户是在微信里完成操作的,电脑浏览器怎么知道“这个用户已经登录成功了”?

这就是整个扫码登录方案真正需要解决的问题。

在个人微信公众号能力有限的情况下,我们可以采用一种折中的实现:

微信公众号回调 + 验证码 + SSE 半长连接 + Session/Cookie

整个方案的核心思想其实非常简单:

使用验证码作为桥梁,把微信公众号中的微信用户和浏览器建立的 SSE 连接关联起来。

理解这句话,基本就理解了整个登录方案。


一、先看完整登录流程

先不要急着看代码,先把整个流程串起来。

假设用户打开网站登录页面。

后端首先给当前浏览器生成一个验证码:

836291

然后浏览器使用这个验证码和服务器建立 SSE 连接。

服务器保存:

836291 → 当前浏览器的 SSE 连接

接下来用户扫码进入微信公众号,并向公众号发送:

836291

微信服务器收到消息以后,会回调我们的服务器。

服务器于是知道:

微信用户:user_A
输入验证码:836291

接着根据验证码查找:

836291
   ↓
SseEmitter
   ↓
浏览器A

这样服务器就知道:

微信里的 user_A,对应的正是正在等待登录的浏览器 A。

服务器完成用户注册/登录,生成 Session 或其他登录凭证,再通过 SSE 主动通知浏览器:

login#登录凭证

浏览器收到消息以后保存 Cookie,刷新页面。

登录完成。

完整流程:

浏览器打开登录页面
        ↓
后端生成验证码
        ↓
836291
        ↓
浏览器建立 SSE 连接
        ↓
服务器保存
836291 → SseEmitter-A
        ↓
用户扫码进入公众号
        ↓
发送 836291
        ↓
微信服务器回调后端
        ↓
后端拿到:
微信用户 + 836291
        ↓
根据 836291 查找 SSE
        ↓
找到浏览器 A
        ↓
创建/查询用户
        ↓
生成登录态
        ↓
SSE 推送 login
        ↓
浏览器保存 Cookie
        ↓
自动登录成功

这里实际上有两条完全不同的链路。

第一条:

浏览器
  ↓
验证码
  ↓
SSE连接

第二条:

微信用户
  ↓
公众号
  ↓
微信服务器
  ↓
回调接口
  ↓
验证码

最后:

             验证码
            /      \
           /        \
       微信用户     浏览器
           \        /
            \      /
             后端
              ↓
           SSE推送
              ↓
           自动登录

所以一定要记住:

验证码就是连接“微信用户”和“浏览器”的桥梁。


二、第一步:接入微信公众号

要实现这套方案,首先得让微信服务器能够找到我们的服务器。

因此需要进入微信公众平台后台配置服务器地址,例如:

https://example.com/wx/callback

以后用户给公众号发送消息时:

用户
 ↓
微信公众号
 ↓
微信服务器
 ↓
我们的 /wx/callback

微信服务器就可以把用户发送的消息转发给我们的后端。

不过第一次配置服务器地址时,微信需要先确认:

这个服务器真的是你的吗?接口真的能正常访问吗?

因此需要进行一次服务器验证。


三、微信公众号的 Token 验证

配置服务器地址时,微信会向我们的接口发送一个 GET 请求。

请求中会携带一些参数,例如:

signature
timestamp
nonce
echostr

其中:

echostr

可以简单理解为微信生成的一个随机字符串。

服务器需要按照微信规定的方式使用:

token
timestamp
nonce

进行签名验证。

验证成功以后,再把:

echostr

原样返回。

例如:

@GetMapping("callback")
@ResponseBody
public String check(HttpServletRequest request) {

    String echoStr =
            request.getParameter("echostr");

    if (StringUtils.isNotEmpty(echoStr)) {
        return echoStr;
    }

    return "";
}

不过要注意:

真正上线时不能只返回 echostr。

还应该验证:

signature
timestamp
nonce

确保这个请求确实来自微信服务器。

所以正确的理解应该是:

微信服务器
    ↓
发送验证请求
    ↓
我们的服务器
    ↓
验证 signature
    ↓
验证通过
    ↓
返回 echostr
    ↓
接入成功

四、接收微信公众号回调

服务器验证完成以后,就可以真正接收微信公众号消息了。

这里有一个需要特别注意的地方:

微信公众号默认使用 XML 进行消息通信。

例如用户向公众号发送:

836291

微信服务器可能给我们的后端发送:

<xml>
    <ToUserName><![CDATA[公众号]]></ToUserName>

    <FromUserName><![CDATA[user123]]></FromUserName>

    <CreateTime>1655700579</CreateTime>

    <MsgType><![CDATA[text]]></MsgType>

    <Content><![CDATA[836291]]></Content>
</xml>

这里最重要的是:

FromUserName
Content

可以简单理解成:

FromUserName = 谁发的消息
Content      = 发了什么

于是:

FromUserName = user123
Content      = 836291

就可以理解为:

微信用户 user123 输入了验证码 836291。

Spring Boot 可以通过:

@PostMapping(
    path = "callback",
    consumes = {
        "application/xml",
        "text/xml"
    },
    produces = "application/xml;charset=utf-8"
)
public BaseWxMsgResVo callback(
        @RequestBody WxTxtMsgReqVo msg) {

    // 处理微信公众号消息

    return null;
}

接收微信发送过来的 XML。

到这里,微信这一侧已经通了。

但是新的问题来了。

服务器现在只知道:

微信用户 user123
输入了
836291

它怎么知道:

836291 对应的是哪一个正在登录的浏览器?

这就要引出整个方案最重要的部分:

SSE 半长连接。


五、为什么需要“半长连接”?

先想一个问题。

假设用户已经在微信里完成登录验证。

服务器已经知道:

登录成功!

但是电脑浏览器并不知道。

怎么通知它?

如果使用普通 HTTP,一般是:

浏览器 → 请求服务器
服务器 → 返回结果
请求结束

例如:

GET /login/code

浏览器请求验证码:

浏览器:给我一个验证码。

服务器:836291。

结束。

后面用户什么时候扫码,服务器没办法通过刚才已经结束的请求主动通知浏览器。

最简单的解决办法是什么?

轮询。


六、不使用 SSE:轮询怎么做?

浏览器可以每隔两秒问服务器:

登录了吗?

例如:

GET /login/status?code=836291

于是:

浏览器 → 登录了吗?

服务器 → 没有。


2秒以后……

浏览器 → 登录了吗?

服务器 → 没有。


2秒以后……

浏览器 → 登录了吗?

服务器 → 登录成功。

这当然可以实现。

但是存在一个明显的问题:

大量请求其实都是没有意义的。

假设用户一分钟以后才扫码。

前端两秒请求一次:

一分钟 ≈ 30次请求

其中前面 29 次可能全部都是:

没有登录

所以我们真正想要的是:

浏览器不要一直问,让服务器在登录成功以后主动告诉浏览器。

这就是 SSE 可以解决的问题。


七、先理解:短连接、长连接和半长连接

这里需要把几个概念区分清楚。

1. 短连接

最容易理解。

客户端 → 请求
服务器 → 响应
连接结束

例如:

GET /login/code

请求验证码。

服务器返回:

836291

这次通信结束。


2. 长连接

所谓长连接,可以简单理解成:

客户端和服务器建立连接以后,不立即断开,而是继续保持,用于后续通信。

典型代表就是:

WebSocket。

WebSocket 建立连接以后:

客户端 ⇄ 服务端

客户端可以主动给服务器发消息:

客户端 → 服务端

服务器也可以主动给客户端发消息:

服务端 → 客户端

而且双方可以独立进行通信。

所以 WebSocket 属于典型的:

全双工双向通信。

可以把它想象成打电话。

两个人建立通话以后:

A ⇄ B

双方都可以说话,也都可以听。


八、那什么是“半长连接”?

这里先说明:

“半长连接”并不是一个严格标准的网络协议名称。

它更多是这个项目为了方便理解 SSE 而使用的一种形象说法。

SSE 全称:

Server-Sent Events

也就是:

服务器发送事件。

浏览器首先向服务器发起一个 HTTP 请求:

浏览器 ──建立连接──> 服务器

但是服务器不会像普通 HTTP 那样:

返回数据
↓
立即结束

而是会一直保持这个连接。

之后服务器有消息的时候,可以不断通过这个连接推送:

浏览器 <──── scan ────── 服务器

浏览器 <──── refresh ─── 服务器

浏览器 <──── login ───── 服务器

因此连接建立以后,主要的数据方向是:

服务器 → 浏览器

而不是:

客户端 ⇄ 服务端

所以项目里把它形象地叫做:

半长连接。


九、“半”到底是什么意思?

这里特别容易产生误解。

它不是说:

连接时间只有长连接的一半

也不是:

HTTP连接只建立了一半

而是为了和 WebSocket 的双向通信做区别。

可以这么记:

WebSocket

客户端 ⇄ 服务端

双向通信
全双工

而 SSE:

客户端 ──建立连接──> 服务端

客户端 <──持续推送── 服务端

建立连接以后,主要由:

服务端 → 客户端

主动发送数据。

所以更准确的技术描述应该是:

SSE 是基于 HTTP 的服务器单向事件推送机制。

而“半长连接”只是项目中的形象说法。


十、为什么扫码登录特别适合 SSE?

因为扫码登录的需求非常特殊。

浏览器并不需要一直给服务器发送消息。

浏览器真正需要的是等待几个状态:

等待扫码
等待验证
等待登录

服务器可能通知:

scan

表示:

二维码已经扫描

或者:

refresh#123456

表示:

验证码刷新了

或者:

login#xxx

表示:

登录成功

所以整个通信需求主要是:

服务器
   ↓
浏览器

而不是:

服务器
   ⇅
浏览器

因此没有必要为了这么简单的需求引入完整的 WebSocket 双向通信。

SSE 就非常合适。


十一、生成登录验证码

现在回到具体实现。

用户打开登录页面以后,前端首先请求:

GET /login/code

后端生成一个验证码。

例如:

836291

代码大致如下:

@GetMapping("/login/code")
public ResVo<QrLoginVo> qrLogin(
        HttpServletRequest request,
        HttpServletResponse response) {

    QrLoginVo vo = new QrLoginVo();

    vo.setCode(
        qrLoginHelper.genVerifyCode(
            request,
            response
        )
    );

    return ResVo.ok(vo);
}

但是这里还有一个问题。

如果用户疯狂刷新页面怎么办?

假设每刷新一次就生成验证码:

第一次:

123456


第二次:

654321


第三次:

888888


第四次:

952700

那么服务器可能不断创建新的验证码以及相关缓存。

所以项目又增加了一层:

deviceId

十二、为什么需要 deviceId?

deviceId 可以理解为:

当前浏览器设备的临时身份标识。

服务器维护:

deviceId → 验证码

例如:

device-A → 836291

这样同一个设备在验证码有效期内不断访问:

/login/code

都可以得到:

836291

而不是不断生成新的验证码。

因此系统中出现了第一个缓存:

deviceCodeCache

保存:

deviceId → code

例如:

device-A
    ↓
836291

它主要解决:

设备和验证码之间的稳定映射问题。


十三、建立 SSE 半长连接

拿到验证码以后,前端继续建立 SSE 连接。

例如请求:

GET /subscribe?id=836291

后端创建:

SseEmitter

例如:

public SseEmitter subscribe()
        throws IOException {

    String deviceId =
        ReqInfoContext
            .getReqInfo()
            .getDeviceId();

    String realCode =
        deviceCodeCache
            .getUnchecked(deviceId);

    SseEmitter sseEmitter =
        new SseEmitter(
            15 * 60 * 1000L
        );

    verifyCodeCache.put(
        realCode,
        sseEmitter
    );

    return sseEmitter;
}

这里真正关键的是:

verifyCodeCache.put(
    realCode,
    sseEmitter
);

因为它建立了:

验证码 → SSE连接

例如:

836291
   ↓
SseEmitter-A

也就意味着:

以后只要拿到 836291,我就可以找到正在等待登录的浏览器 A。


十四、为什么需要两个缓存?

现在整个设计就清晰了。

第一个:

deviceCodeCache

保存:

deviceId → code

例如:

device-A → 836291

主要作用:

防止用户频繁刷新页面导致验证码不断变化。

第二个:

verifyCodeCache

保存:

code → SseEmitter

例如:

836291 → SseEmitter-A

主要作用:

微信回调拿到验证码以后,可以根据验证码找到对应浏览器。

因此整个映射关系:

deviceId
    ↓
验证码
    ↓
SseEmitter
    ↓
浏览器

例如:

device-A
    ↓
836291
    ↓
SseEmitter-A
    ↓
浏览器A

这是整个方案最核心的数据结构之一。


十五、为什么刷新页面要处理旧 SSE?

假设浏览器 A 已经建立了:

836291 → SseEmitter-1

然后用户刷新页面。

新的页面又建立:

836291 → SseEmitter-2

如果旧连接不释放,就可能出现:

836291
  ↓
到底应该通知哪个连接?

所以重新建立 SSE 时,通常需要把之前的连接关闭:

SseEmitter oldSse =
    verifyCodeCache.getIfPresent(realCode);

if (oldSse != null) {
    oldSse.complete();
}

然后保存新的:

verifyCodeCache.put(
    realCode,
    sseEmitter
);

最终保证:

一个验证码
   ↓
一个当前有效的 SSE 连接

这样关系就不会乱。


十六、SSE 连接也不能永久保存

SSE 虽然叫“长连接”,但不能真的永远挂着。

例如可以设置:

new SseEmitter(
    15 * 60 * 1000L
);

也就是:

15分钟

超时以后:

sseEmitter.onTimeout(() -> {

    verifyCodeCache.invalidate(realCode);

    sseEmitter.complete();
});

如果连接异常:

sseEmitter.onError(e -> {

    verifyCodeCache.invalidate(realCode);

    sseEmitter.complete();
});

这样可以避免:

浏览器已经关闭
↓
服务器还一直保存旧连接
↓
缓存越来越多

十七、前端如何建立 SSE?

浏览器原生提供:

EventSource

可以非常方便地建立 SSE。

例如:

function buildConnect(code) {

    const subscribeUrl =
        "/subscribe?id=" + code;

    const source =
        new EventSource(subscribeUrl);

}

这一步执行以后:

浏览器
   │
   │ HTTP请求
   ▼
/subscribe
   │
   ▼
服务器创建 SseEmitter

连接不会马上关闭。

而是:

浏览器
   │
   │
   │ SSE保持
   │
   │
服务器

等待服务器主动推送消息。


十八、前端如何接收服务器消息?

通过:

source.onmessage = function(event) {

    const text = event.data;

    console.log(
        "receive: " + text
    );

};

假设服务器发送:

scan

前端可以:

if (text === "scan") {

    stateTag.innerText = "已扫描";

}

如果服务器发送:

refresh#952700

前端更新验证码:

if (text.startsWith("refresh#")) {

    const newCode =
        text.substring(8).trim();

    codeTag.innerText = newCode;

}

如果服务器发送:

login#xxx

说明:

登录成功

于是:

if (text.startsWith("login#")) {

    document.cookie =
        text.substring(6);

    source.close();

    window.location.reload();
}

最终完成自动登录。


十九、真正的自动登录发生在哪里?

现在来到整个流程最关键的地方。

假设浏览器 A 的验证码是:

836291

服务器已经保存:

device-A
    ↓
836291
    ↓
SseEmitter-A

用户现在扫码进入公众号,并发送:

836291

微信服务器回调:

POST /wx/callback

携带:

FromUserName = wx-user-A
Content      = 836291

服务器于是知道:

微信用户A
   ↓
输入了
   ↓
836291

接下来查询:

SseEmitter emitter =
    verifyCodeCache
        .getIfPresent("836291");

查到:

836291
   ↓
SseEmitter-A
   ↓
浏览器A

到这里最关键的一步完成了:

微信用户 A 和浏览器 A 被关联起来了。

靠的是什么?

不是 WebSocket。

不是 Session。

也不是 Cookie。

而是:

验证码。


二十、创建用户登录态

知道微信用户是谁以后,后端就可以处理正常的登录逻辑。

例如根据微信用户标识查询:

这个微信用户是否已经注册?

如果没有:

创建用户

例如初始化:

用户名
头像
用户信息

如果已经存在:

直接查询用户

接下来创建:

Session

或者其他登录凭证。

例如:

SESSION=abc123

然后服务器通过之前找到的:

SseEmitter-A

发送:

emitter.send(
    "login#SESSION=abc123"
);

消息就顺着之前一直保持的 SSE 连接:

服务器
   │
   │ login#SESSION=abc123
   ▼
浏览器A

前端收到以后:

document.cookie =
    text.substring(6);

然后:

window.location.reload();

浏览器重新请求页面时携带 Cookie。

服务器识别 Session。

最终:

登录成功

二十一、把完整流程重新走一遍

现在再看整个流程,就非常简单了。

第一步:打开登录页面

浏览器
 ↓
GET /login/code
 ↓
服务器

服务器根据 deviceId 获取验证码:

device-A → 836291

返回:

836291

第二步:建立半长连接

浏览器:

GET /subscribe?id=836291

服务器创建:

SseEmitter-A

保存:

836291 → SseEmitter-A

此时:

浏览器A
   │
   │ SSE连接
   │
服务器

连接一直保持。


第三步:用户扫码

用户:

扫描公众号二维码

进入公众号。

然后发送:

836291

第四步:微信回调

微信服务器:

POST /wx/callback

告诉我们的服务器:

微信用户:user-A
验证码:836291

第五步:验证码匹配浏览器

服务器查询:

836291
   ↓
SseEmitter-A

于是知道:

user-A
   ↓
对应
   ↓
浏览器A

第六步:创建登录态

服务器:

查询/注册用户
      ↓
生成 Session
      ↓
SESSION=abc123

第七步:SSE 推送

服务器:

SseEmitter-A.send(
    "login#SESSION=abc123"
)

浏览器收到:

login#SESSION=abc123

第八步:自动登录

浏览器:

保存 Cookie
   ↓
关闭 SSE
   ↓
刷新页面
   ↓
携带 Cookie
   ↓
服务器识别用户
   ↓
登录成功

到这里整个流程结束。


二十二、为什么这里不用 WebSocket?

这也是面试很容易继续追问的问题。

WebSocket 当然可以实现。

但是我们分析需求:

扫码登录过程中,浏览器真正需要发送多少实时消息?

基本没有。

主要都是服务器通知浏览器:

已扫码
验证码更新
登录成功

所以需求本质上是:

服务器
   ↓
浏览器

而 WebSocket 提供的是:

服务器
   ⇅
浏览器

能力更强,但这个场景未必需要。

因此使用 SSE:

实现简单
基于 HTTP
浏览器原生支持 EventSource
适合服务器单向通知

已经完全够用了。

所以:

扫码登录
支付结果通知
订单状态更新
任务执行进度
AI流式输出
消息提醒

这些主要由服务器主动推送状态的场景,都可以考虑 SSE。

而:

在线聊天
多人实时协作
实时游戏
双向实时控制

这种双方都需要频繁主动发送数据的场景,更适合 WebSocket。


二十三、SSE 和 WebSocket 到底怎么选?

可以简单记:

对比 SSE WebSocket
通信方向 服务端主要向客户端推送 双向
协议基础 HTTP WebSocket 协议
浏览器使用 EventSource WebSocket
实现复杂度 相对简单 相对复杂
自动重连 EventSource 原生支持 通常自己处理
扫码登录 很适合 可以,但能力偏多
在线聊天 不太合适 很适合

所以不要简单理解成:

WebSocket 比 SSE 高级

正确的理解应该是:

根据业务通信模型选择合适的技术。


二十四、为什么项目最开始设计成两个接口?

现在我们有:

/login/code

负责:

获取验证码

以及:

/subscribe

负责:

建立 SSE

为什么不能一次完成?

从纯技术角度:

当然可以重新设计。

之所以拆成两个接口,很大程度上是历史原因。

早期的登录流程可能是:

用户关注公众号
      ↓
公众号生成验证码
      ↓
用户回到网站
      ↓
手动输入验证码
      ↓
完成登录

后来觉得操作太麻烦,于是改成:

网站生成验证码
      ↓
用户发给公众号
      ↓
SSE自动通知网页
      ↓
自动登录

但是为了复用以前的代码,没有彻底重构,于是最终保留:

获取验证码接口
+
建立 SSE 接口

这也是现实项目非常常见的现象:

代码结构不一定都是从零设计出来的,很多时候是随着业务不断迭代演化出来的。


二十五、这个方案有什么安全问题?

这部分非常重要。

假设:

用户A验证码:666

用户B验证码:888

服务器:

666 → SseEmitter-A

888 → SseEmitter-B

如果微信用户 A 不小心或者恶意发送:

888

服务器查询:

888
   ↓
SseEmitter-B

那么:

微信用户A

可能就会登录到:

浏览器B

这显然存在风险。

本质原因是:

验证码实际上承担了一次性临时登录凭证的作用。

如果验证码只有:

666
888
999

这种非常简单的数字,那么非常容易:

猜测
碰撞
误输入
恶意尝试

二十六、正式项目应该怎么优化?

至少应该做到:

验证码足够随机
        ↓
有效期足够短
        ↓
只能使用一次
        ↓
登录成功立即失效
        ↓
限制错误尝试次数
        ↓
接口限流
        ↓
校验微信回调签名

除此之外,更成熟的方案可以使用:

随机 state
随机 ticket
一次性 token
二维码唯一标识

而不是:

666
888
999

这种简单验证码。

比如:

8F2K9X7P

或者直接使用足够随机的一次性 token。

核心原则是:

用于关联浏览器和微信用户的凭证必须足够随机、短期有效、一次性消费。


二十七、缓存还要注意什么?

除了验证码安全,还有缓存生命周期。

例如:

deviceId → code

不能永久保存。

code → SseEmitter

更不能永久保存。

否则用户关闭浏览器以后:

浏览器没了
↓
SseEmitter还在
↓
验证码还在
↓
缓存不断堆积

因此应该设置:

验证码 TTL
SSE 超时时间
登录成功主动删除
异常主动删除
连接关闭主动清理

也就是说,完整生命周期应该是:

创建验证码
    ↓
建立 SSE
    ↓
等待用户扫码
    ↓
登录成功
    ↓
验证码失效
    ↓
SSE关闭
    ↓
缓存删除

而不是只考虑:

怎么创建

不考虑:

怎么销毁

二十八、最终架构图

把所有内容压缩到一张图里:

                  浏览器
                     │
                     │ ① 获取验证码
                     ▼
                 后端服务器
                     │
                     │ deviceId → code
                     ▼
                  836291
                     │
                     │ ② 建立 SSE
                     ▼
             code → SseEmitter
                     │
                     │
                     │ 半长连接保持
                     │
                     │
用户 ──③扫码──> 微信公众号
                     │
                     │ ④发送 836291
                     ▼
                 微信服务器
                     │
                     │ ⑤ XML 回调
                     ▼
                 后端服务器
                     │
                     │ 获取微信用户
                     │ +
                     │ 获取验证码
                     ▼
                   836291
                     │
                     │ ⑥查询缓存
                     ▼
                SseEmitter
                     │
                     │ ⑦生成 Session
                     │
                     │ ⑧推送 login
                     ▼
                   浏览器
                     │
                     │ 保存 Cookie
                     ▼
                  登录成功

如果只允许记住一个数据结构,就记住:

deviceId
   ↓
code
   ↓
SseEmitter

如果只允许记住一条业务链路,就记住:

微信用户
   ↓
发送验证码
   ↓
微信回调
   ↓
根据验证码找到 SSE
   ↓
找到对应浏览器
   ↓
生成登录态
   ↓
SSE通知浏览器
   ↓
自动登录

二十九、面试时怎么讲?

如果面试官问:

你们微信公众号扫码登录是怎么实现的?

可以这样回答:

我们的实现主要是微信公众号回调、验证码映射和 SSE。用户打开登录页面以后,服务端首先根据当前设备生成一个临时验证码,然后浏览器基于这个验证码和服务端建立 SSE 连接,后端维护验证码到 SseEmitter 的映射。用户扫码进入公众号并发送验证码以后,微信服务器会回调我们的接口,我们根据微信用户标识识别用户,同时根据验证码找到对应的 SseEmitter,也就是找到正在等待登录的浏览器。完成用户注册或登录并生成 Session 后,再通过 SSE 把登录结果主动推送给浏览器,浏览器保存登录态并刷新页面,从而完成自动登录。

如果面试官继续问:

什么是半长连接?

回答:

项目里的半长连接实际上指的是 SSE,它并不是严格意义上的标准网络术语。浏览器先通过 HTTP 和服务端建立一个持续保持的连接,建立以后主要由服务端主动向客户端推送事件。相比 WebSocket 的全双工双向通信,SSE 更偏向服务端到客户端的单向推送,所以项目里形象地把它叫做半长连接。

继续问:

为什么不用 WebSocket?

回答:

因为扫码登录主要需要服务端通知浏览器扫码状态和登录结果,不需要双方频繁进行双向实时通信。SSE 基于 HTTP,浏览器原生支持 EventSource,实现成本更低,而且能够满足服务端主动推送的需求,所以这个场景下使用 SSE 更轻量。

继续问:

为什么不用轮询?

回答:

轮询也可以实现,但浏览器需要不断请求登录状态,会产生很多无效请求。SSE 建立连接以后,用户真正扫码登录成功时再由服务器主动推送结果,可以减少无意义请求,同时登录状态变化也能更及时地通知浏览器。

继续问:

两个缓存分别是干什么的?

回答:

deviceCodeCache 保存 deviceId 到验证码的映射,主要解决用户刷新页面时验证码频繁变化的问题;verifyCodeCache 保存验证码到 SseEmitter 的映射,主要用于微信公众号回调拿到验证码以后,快速找到对应的浏览器 SSE 连接。

最后如果问:

这个设计有什么问题?

回答:

最大的问题之一是验证码安全。如果验证码过短或者容易猜,本质上可能出现验证码碰撞或者冒用,所以正式系统应该使用高随机性、短有效期、一次性消费的临时凭证,同时增加尝试次数限制、接口限流和微信回调签名校验。另外 SSE 和验证码缓存也需要设置合理的超时时间,并在登录成功、连接异常或者超时后及时清理。


三十、总结

微信公众号扫码登录看起来涉及很多东西:

微信公众号
微信回调
XML
验证码
deviceId
缓存
SSE
SseEmitter
Session
Cookie
JavaScript

第一次看源码很容易陷进去。

但把细节全部拿掉以后,真正核心的设计只有三件事。

第一件事:微信怎么找到我们的服务器?

靠:

微信公众号回调

第二件事:微信用户怎么和浏览器对应起来?

靠:

验证码

服务器维护:

验证码 → SSE连接

第三件事:服务器怎么主动告诉浏览器登录成功?

靠:

SSE

也就是项目中所说的:

半长连接

所以最终可以把整个方案压缩成一句话:

浏览器先通过验证码与服务端建立 SSE 映射,用户在微信公众号中发送验证码后,微信服务器回调业务后端,后端根据验证码找到对应的 SSE 连接,完成用户身份识别和登录态创建,再通过 SSE 主动通知浏览器,从而实现微信公众号扫码后的自动登录。

真正理解这句话以后,再回头看:

deviceCodeCache
verifyCodeCache
SseEmitter
EventSource
callback
Session
Cookie

这些代码就不再是一堆零散的技术点,而只是围绕这条登录链路展开的具体实现。

微信公众号扫码登录是怎么实现的?一篇讲懂 SSE、半长连接与验证码映射
http://clxhxhhr.top/posts/432/
作者
clxstart
发布于
2026-09-03
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。