扫码基石:构建视觉化的连接钥匙

二维码(QR Code)是连接物理世界与数字世界的“虫洞”。在登录系统中,它承载着一个临时的身份信标

1. 生成有效二维码

登录二维码通常包含一个加密的 URL 或一个唯一的 UUID(通用唯一识别码)。

  • 唯一性: 每一对扫描动作都必须对应一个独一无二的 ID。
  • 时效性: 二维码必须配合 Redis 设置过期时间(如 2 分钟),逾期自动失效。

Redis 最核心的用法是 Key-Value (键值对)。
在本项目中,我们将用户的微信 OpenID 作为 Key,生成的验证码作为 Value:

// 存储验证码,并设置 5 分钟过期
err := rdb.Set(ctx, "user:123:code", "8888", 5*time.Minute).Err()

// 读取验证码
val, err := rdb.Get(ctx, "user:123:code").Result()

在 Go 生态中,我们使用 skip2/go-qrcode 等库来完成像素的绘制:

// 生成二维码字节数组
var png []byte
png, _ = qrcode.Encode("91demo.top"+sessionID, qrcode.Medium, 256)

为了防止用户伪造扫码请求,二维码里的内容通常是加密的或者是不可预测的长随机数(UUID)。只有真实存在的 ID 才能通过后端的 Redis 校验。

2. 传输二维码

我们不希望在用户硬盘上产生大量的临时 .png 文件。
最佳实践是将二进制图片转换为 Base64 字符串,通过结构体返回给前端:

// 转换为前端可直接识别的 Data URL
base64Img := "data:image/png;base64," + base64.StdEncoding.EncodeToString(png)

为了让前端不至于崩溃,后端必须返回统一的格式。

func HandleLogin(c *gin.Context) {
    // 逻辑处理...
    c.JSON(200, gin.H{
        "code": 1,
        "content": base64Img,
    })
}

在前端,我们不再需要引入沉重的第三方库来做简单的请求。浏览器原生提供的 Fetch API 简洁且基于 Promise。

// 向 豆子实验室发起请求
fetch("api.dou-dou.top")
  .then((response) => response.json())
  .then((data) => console.log(data))
  .catch((error) => console.error("探索失败:", error));

需要注意的是,当尝试从 91demo.top 请求 dou-dou.top 的数据时,浏览器会为了安全触发跨域资源共享 (CORS) 检查。

解决之道: 这通常需要后端( 豆子实验室的服务器)在响应头中明确标记 Access-Control-Allow-Origin。理解这一点,是前端进阶的必经之路。

拿到的原始数据通常是 JSON 格式。我们需要将其解析为 JavaScript 对象,并利用模板引擎或 DOM 操作将其渲染到关卡页面上。

使用HTML的img标签,现在二维码应该可以正常显示在网页上了。

3. 读取二维码

扫码动作本质上是将桌面端的 SessionID 传递给移动端。

这里我们使用小程序实现扫码功能。小程序提供了极其简便的接口来调用摄像头。它不仅能识别标准的二维码(QR Code),还能识别条形码。

// 小程序端:发起扫码
wx.scanCode({
  onlyFromCamera: true, // 只允许相机扫码,不允许从相册选择
  success: (res) => {
    // res.result 包含了二维码中的内容,例如:sid=52025
    const sid = parseQuery(res.result).sid;
    this.confirmLogin(sid);
  },
});

当 wx.scanCode 成功后,小程序拿到了二维码里的“信标”(SessionID)。
此时,小程序会自动发起我们的 wx.request,向后端发送:“我是用户 A,我刚扫到了 ID 为 52025 的设备,请记录。”

这里使用了小程序提供的 API 用于与开发者服务器通信。与浏览器不同,它没有跨域限制,但要求通信必须使用安全的 HTTPS 协议。

// 小程序端:确认网站登录
wx.request({
  url: "api.91demo.top",
  method: "POST",
  data: {
    token: "xxx", // 扫码拿到的标识
    openid: "user_123",
  },
  success: (res) => {
    console.log("联动指令已下达");
  },
});

小程序并不是运行在标准浏览器环境下,虽然它支持部分 Cookie 模拟,但在 开发实践中,通过 Header 传递自定义 Token 是更可靠、更现代的选择。

在实际生产中,为了防止接口被恶意调用,我们通常会在请求头中加入 Signature (签名)。这需要利用小程序的 session_key 对请求数据进行加密,确保请求确实来自用户的手机。

4. 后端验证

我们先来介绍一下小程序身份。当小程序传入sessionID时,也携带了用户的小程序身份code,我们需要将这个code从临时凭证转到永久 ID

微信小程序的登录流程是典型的 三方安全鉴权 模型(小程序客户端、你的服务器、微信服务器)。

首先是临时凭证:Code 的诞生,在小程序前端,我们调用 wx.login()。这个函数会返回一个名为 code 的临时凭证。

  • 时效性: code 只有 5 分钟有效期。
  • 唯一性: 每次调用生成的 code 都不相同,且只能使用一次。

然后,当我们的服务器拿到 code 后,需要配合 AppIDAppSecret,从后端向微信接口发起请求:

// Go 伪代码:请求微信 code2Session 接口
resp, err := http.Get("api.weixin.qq.com" + code + "&grant_type=authorization_code")

如果 code 有效,微信服务器会返回:

  • OpenID: 用户的唯一标识(针对当前小程序)。
  • SessionKey: 会话密钥,用于后续对加密数据(如手机号、运动步数)的解密。

这里的OpenID就是我们想要的值,它标识了用户在小程序中的身份。

设计这么复杂是为了防止 AppSecret 泄露。AppSecret 永远只留在我们的服务器上,而 code 在公网传输即使被截获,没有密钥也无法换取用户信息。

好了,有了小程序之后,我们需要验证二维码中的sessionID。我们是在生成二维码时将网站生成的 SessionID 存入了 Redis。

当后端接收到小程序的 wx.request 后,会执行以下逻辑:
提取: 获取小程序传来的 SessionID。
校验: 在 Redis 中查询该 ID 是否存在。
标记: 将该 ID 的状态改为“已授权”,并写入用户信息。
通知: 触发 WebSocket 或轮询,告诉网站:“用户已点击确认,准许进入”。

好了,我们来看最后一步,Web端如何知道扫码已经成功。

5. Web端获取登录状态

我们需要知道的是,当二维码显示在界面后,前端需要不断询问后端:“有人扫我了吗?”

这里有两个实现方案:

  • 方案 A: 简单轮询(每 2 秒发一次 HTTP 请求)。
  • 方案 B: 使用 WebSocket,当扫码成功时,后端主动推送到桌面端。

方案A的核心在于启动一个定时器,然后不断轮询后台,当登录成功后,前端自动调整到授权的面板页面。

方案B使用了WebSocket,我们来简单介绍一下:

HTTP 协议是“一问一答”的,这种模式在实时性要求高的场景下显得非常笨重。WebSocket 的出现彻底改变了这一点。

一旦 WebSocket 握手成功,连接将升级并保持开启状态。

  • 双向性: 不仅客户端可以随时发消息,服务器也能在有新动态时立即“推”给前端。
  • 轻量: 相比 HTTP,它省去了大量的 Header 信息,数据传输极其高效。

在 JavaScript 中,建立连接非常简单:

const socket = new WebSocket("wss://api.dou-dou.top/ws");

// 监听连接开启
socket.onopen = () => console.log("心跳建立成功");

// 接收来自 豆子实验室的推送
socket.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log("收到实时数据:", data);
};

网络环境是复杂的,连接可能会因为路由超时或断网而静默中断。
为了维持连接,我们需要实现 心跳检测 (Heartbeat)。就像人类的脉搏一样,客户端每隔一段时间发送一个特定的微型包(Ping),服务器回复一个(Pong),确保通道始终畅通。

优秀的软件必须具备“韧性”,这就需要重连机制。如果 WebSocket 意外关闭,前端需要有一套重连算法(通常采用指数退避策略),在不压垮服务器的前提下,尝试自动找回连接。

无论使用哪种方案,都是将后端的身份验证状态告知前端。

这样我们就完成了二维码登录。在我的网站运行一段时间后,我发现还有更好的办法来登录。这就是小程序码登录,这个登录体验更流程,更便捷。

小程序码登录

1. 什么是小程序码?

小程序码(太阳码)是微信独有的视觉交互方案,它比普通二维码更安全,因为其解析过程完全在微信内部完成。

在请求微信生成小程序码之前,你的 Go 后端必须先向微信换取一个“通行证”—— access_token

  • 重要性: 它是你调用微信服务器 API 的唯一身份证明。
  • 存储: 由于它有 2 小时的有效期,建议使用我们在 2.2 节学到的 Redis 进行缓存,避免频繁调用被限流。

微信提供了几个接口,最常用的是 getUnlimited。它允许你传递一个 scene 参数(场景值)。

  • Scene 参数: 我们将 5.1 节生成的 SessionID 放入 scene 中。
  • 限制: 这个接口生成的码数量不受限制,非常适合大规模的登录业务。
// Go 伪代码:请求微信生成小程序码
reqBody := map[string]interface{}{
    "scene": "sid=52025",
    "page":  "pages/index/index",
    "width": 430,
}
// 微信会直接返回图片的二进制流 (Binary Stream)

与普通 API 返回 JSON 不同,微信这个接口返回的是图片的 二进制数据。
在 Go 后端,我们需要读取响应体的 Body,并将其再次通过 Base64 编码,发送给前端显示。

2. 小程序码登录

当我们打开微信扫描生成的带参数小程序码时,微信会将参数放入 scene 字段。在小程序的 onLoad 函数中,我们需要对其进行解密或解析:

onLoad(options) {
  if (options.scene) {
    // 微信会将 scene 进行 URL 编码,需要解码
    const scene = decodeURIComponent(options.scene);
    // 提取我们在后端埋下的 sid=52025
    this.setData({ sessionId: getQueryString(scene, 'sid') });
  }
}

用户在小程序点击“确认登录”后,小程序通过 wx.request 将 sessionId 和 openid 发回给 Go 后端。

后端执行以下“点火”逻辑:
核对: 从 Redis 确认该 sessionId 是否有效。
授权: 将用户信息与该会话绑定,标记状态为 AUTHORIZED。
触达: 通过 WebSocket 或 Event 立即向桌面端发送指令。

这个流程和二维码一致。都是验证用户身份并绑定sessionID的过程。

最后,想尝试吗?可以点击Lab网站体验一下。