在日常开发中,扫码登录几乎成了 PC 端网站的标配——从微信、支付宝到各类企业管理后台,都支持用手机 App 一扫即登。作为后端开发者,我们不仅要会用,更要理解其背后的设计思想和实现细节。
本文将从零开始,带你完整实现一套扫码登录的后端服务,技术栈为 Spring Boot 3.x + Redis + JJWT。全文分为原理篇和实战篇,在实战篇中我会对每个核心代码片段进行详细讲解,让你不仅会复制代码,更懂其所以然。
扫码登录的本质是 “已认证设备(手机)帮未认证设备(PC)完成身份验证” 。整个过程只需三个核心步骤:
为了更直观,我们画一张时序图:

二维码对应的令牌(Token)在服务端有以下几种状态:
| 状态 | 说明 |
|---|---|
| waiting | 初始状态,等待手机扫码 |
| scanned | 手机已扫码,等待用户点击“确认” |
| confirmed | 用户已确认,PC 端可获取登录凭证 |
| expired | 二维码超时(如 2 分钟)或被主动删除 |
| 组件 | 选型 | 版本 |
|---|---|---|
| 后端框架 | Spring Boot | 3.2.0 |
| JDK | Java | 17+ |
| 缓存 | Redis (Lettuce) | 随 Spring Boot 3.x |
| JWT 生成 | JJWT | 0.12.3 |
| 序列化 | Jackson | 随 Spring Boot 3.x |
复制代码src/main/java/com/example/qrlogin/
├── QrLoginApplication.java # 启动类
├── config/
│ └── RedisConfig.java # Redis 配置
├── controller/
│ └── QrLoginController.java # REST API 控制器
├── service/
│ └── QrLoginService.java # 核心业务逻辑
├── dto/ # 请求/响应对象
│ ├── ApiResponse.java
│ ├── QrCreateResponse.java
│ ├── QrScanRequest.java
│ ├── QrConfirmRequest.java
│ └── QrStatusResponse.java
├── enums/
│ └── QrStatus.java # 状态枚举
└── exception/
└── GlobalExceptionHandler.java # 全局异常处理
复制代码<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
d">
<modelVersion>4.0.0</modelVersion> <parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
<relativePath/>
</parent> <groupId>com.example</groupId>
<artifactId>qr-login</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging> <properties>
<java.version>17</java.version>
<jjwt.version>0.12.3</jjwt.version>
</properties> <dependencies>
<!-- Spring Boot Web 提供 REST API 支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency> <!-- Spring Boot Redis 提供 RedisTemplate 操作 Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency> <!-- JJWT 用于生成和解析 JWT 登录凭证 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency> <!-- Lombok 简化 POJO 代码(getter/setter 等) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency> <!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies> <build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
讲解:这里使用了 Spring Boot 3.2.0 的 parent,因此所有依赖版本都已被管理。spring-boot-starter-data-redis 默认使用 Lettuce 客户端,性能优秀。JJWT 0.12.x 版本适配了新的 Java API,且签名方法更简洁。
复制代码spring:
redis:
host: localhost
port: 6379
database: 0
timeout: 5000msqr:
login:
expire-seconds: 120 # 二维码有效期(秒)
jwt-secret: ${JWT_SECRET:change-me-in-production} # 从环境变量读取,提供默认值
jwt-expire-days: 7
讲解:Redis 配置根据实际情况修改。jwt-secret 建议在生产环境通过环境变量注入,避免硬编码。expire-seconds 和 jwt-expire-days 分别控制二维码和 JWT 的有效时间。
复制代码package com.example.qrlogin.enums;public enum QrStatus {
WAITING, SCANNED, CONFIRMED, EXPIRED
}
讲解:这四种状态对应二维码的完整生命周期,清晰明了。
复制代码package com.example.qrlogin.dto;import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse<T> {
private boolean success;
private String message;
private T data; public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, "success", data);
} public static <T> ApiResponse<T> success(String message, T data) {
return new ApiResponse<>(true, message, data);
} public static <T> ApiResponse<T> error(String message) {
return new ApiResponse<>(false, message, null);
}
}
讲解:统一的前端返回格式,包含 success 标志、提示信息和数据体,方便前端统一处理。
复制代码// QrCreateResponse.java
@Data @NoArgsConstructor @AllArgsConstructor
public class QrCreateResponse {
private String token;
private Integer expireSeconds;
}// QrScanRequest.java
@Data
public class QrScanRequest {
private String token;
private String userId;
}// QrConfirmRequest.java
@Data
public class QrConfirmRequest {
private String token;
private String userId;
}// QrStatusResponse.java
@Data @NoArgsConstructor @AllArgsConstructor
public class QrStatusResponse {
private String status;
private String message;
private String accessToken;
}
讲解:DTO 严格按照接口需求定义,token 是二维码的唯一标识,userId 是手机端登录用户的 ID(实际生产可能用更复杂的用户标识)。
复制代码package com.example.qrlogin.config;import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;@Configuration
public class RedisConfig { @Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// 设置 Key 的序列化方式为 String,方便在 Redis 客户端查看
template.setKeySerializer(new StringRedisSerializer());
// 设置 Value 的序列化方式为 JSON,便于存储复杂对象
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
// Hash 的 Key 和 Value 同样设置
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
template.afterPropertiesSet();
return template;
}
}
讲解:这里自定义了 RedisTemplate,统一使用 JSON 序列化,这样我们在存储哈希时可以直接放入 Map,Redis 会自动转为 JSON 字符串存储,取用时也能自动转回 Map 或对象,非常方便。
复制代码package com.example.qrlogin.service;import com.example.qrlogin.dto.QrCreateResponse;
import com.example.qrlogin.dto.QrStatusResponse;
import com.example.qrlogin.enums.QrStatus;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;@Service
public class QrLoginService { @Autowired
private RedisTemplate<String, Object> redisTemplate; @Value("${qr.login.expire-seconds:120}")
private int expireSeconds; @Value("${qr.login.jwt-secret}")
private String jwtSecret; @Value("${qr.login.jwt-expire-days:7}")
private int jwtExpireDays; private static final String REDIS_KEY_PREFIX = "qrcode:"; // ---------- 1. 生成二维码 ----------
/**
* 为 PC 端生成一个新的二维码令牌。
* 生成一个 UUID 作为 token,存入 Redis,状态为 WAITING,并设置 TTL。
*/
public QrCreateResponse createQrCode() {
// 生成无横线的 UUID 作为 token
String token = UUID.randomUUID().toString().replace("-", "");
String key = REDIS_KEY_PREFIX + token; // 用 Map 表示要存入 Redis Hash 的字段
Map<String, Object> hash = new HashMap<>();
hash.put("status", QrStatus.WAITING.name());
hash.put("userId", ""); // 初始为空
hash.put("createTime", System.currentTimeMillis()); // 存入 Redis
redisTemplate.opsForHash().putAll(key, hash);
// 设置过期时间
redisTemplate.expire(key, expireSeconds, TimeUnit.SECONDS); return new QrCreateResponse(token, expireSeconds);
} // ---------- 2. 手机扫码 ----------
/**
* 手机端扫码后调用,传入 token 和当前登录用户 ID。
* 检查 token 是否存在且状态为 WAITING,若是则更新为 SCANNED 并绑定 userId。
*/
public boolean scanQrCode(String token, String userId) {
String key = REDIS_KEY_PREFIX + token;
// 检查 key 是否存在
if (Boolean.FALSE.equals(redisTemplate.hasKey(key))) {
return false;
}
// 获取当前状态
String status = (String) redisTemplate.opsForHash().get(key, "status");
if (!QrStatus.WAITING.name().equals(status)) {
return false; // 只有 WAITING 状态才能被扫描
}
// 更新状态和 userId
redisTemplate.opsForHash().put(key, "status", QrStatus.SCANNED.name());
redisTemplate.opsForHash().put(key, "userId", userId);
// 刷新过期时间,给用户足够时间确认
redisTemplate.expire(key, expireSeconds, TimeUnit.SECONDS);
return true;
} // ---------- 3. 手机确认 ----------
/**
* 手机端用户点击“确认登录”后调用。
* 校验 token 状态是否为 SCANNED,并且传入的 userId 与扫码时绑定的 userId 一致。
* 全部通过后生成 JWT,更新状态为 CONFIRMED,并将 JWT 存入 Redis(供 PC 轮询获取)。
*/
public String confirmQrCode(String token, String userId) {
String key = REDIS_KEY_PREFIX + token;
if (Boolean.FALSE.equals(redisTemplate.hasKey(key))) {
return null;
}
String status = (String) redisTemplate.opsForHash().get(key, "status");
if (!QrStatus.SCANNED.name().equals(status)) {
return null; // 必须是已扫描状态
}
String storedUserId = (String) redisTemplate.opsForHash().get(key, "userId");
if (!userId.equals(storedUserId)) {
return null; // 用户不匹配,防止恶意确认
}
// 生成 JWT 登录凭证
String accessToken = generateJwt(userId);
// 更新状态,并保存 accessToken
redisTemplate.opsForHash().put(key, "status", QrStatus.CONFIRMED.name());
redisTemplate.opsForHash().put(key, "accessToken", accessToken);
// 保留 60 秒供 PC 轮询获取,之后自动过期
redisTemplate.expire(key, 60, TimeUnit.SECONDS);
return accessToken;
} // ---------- 4. PC轮询 ----------
/**
* PC 端定时调用此接口查询二维码状态。
* 根据状态返回不同的信息,如果是 CONFIRMED,则返回 accessToken 并删除二维码(一次性使用)。
*/
public QrStatusResponse pollStatus(String token) {
String key = REDIS_KEY_PREFIX + token;
if (Boolean.FALSE.equals(redisTemplate.hasKey(key))) {
return new QrStatusResponse(QrStatus.EXPIRED.name(), "二维码已过期", null);
}
String status = (String) redisTemplate.opsForHash().get(key, "status");
if (QrStatus.WAITING.name().equals(status)) {
return new QrStatusResponse(QrStatus.WAITING.name(), "等待扫码", null);
} else if (QrStatus.SCANNED.name().equals(status)) {
return new QrStatusResponse(QrStatus.SCANNED.name(), "已扫码,等待确认", null);
} else if (QrStatus.CONFIRMED.name().equals(status)) {
String accessToken = (String) redisTemplate.opsForHash().get(key, "accessToken");
// 返回后立即删除,确保一次性使用
redisTemplate.delete(key);
return new QrStatusResponse(QrStatus.CONFIRMED.name(), "登录成功", accessToken);
} else {
return new QrStatusResponse(QrStatus.EXPIRED.name(), "未知状态", null);
}
} // ---------- 5. 刷新二维码 ----------
public QrCreateResponse refreshQrCode() {
// 直接复用生成逻辑
return createQrCode();
} // ---------- 生成 JWT ----------
private String generateJwt(String userId) {
// 使用 HMAC-SHA256 密钥
SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
long now = System.currentTimeMillis();
long exp = now + TimeUnit.DAYS.toMillis(jwtExpireDays);
return Jwts.builder()
.claim("userId", userId)
.claim("loginTime", now)
.issuedAt(new Date(now))
.expiration(new Date(exp))
.signWith(key) // 默认使用 HS256
.compact();
}
}
讲解:这个 Service 封装了所有核心业务逻辑。
复制代码package com.example.qrlogin.controller;import com.example.qrlogin.dto.*;
import com.example.qrlogin.service.QrLoginService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;@RestController
@RequestMapping("/api/qrcode")
public class QrLoginController { @Autowired
private QrLoginService qrLoginService; @PostMapping("/create")
public ApiResponse<QrCreateResponse> create() {
return ApiResponse.success(qrLoginService.createQrCode());
} @PostMapping("/scan")
public ApiResponse<String> scan(@RequestBody QrScanRequest request) {
boolean ok = qrLoginService.scanQrCode(request.getToken(), request.getUserId());
return ok ? ApiResponse.success("扫码成功,请确认登录") : ApiResponse.error("二维码无效或已过期");
} @PostMapping("/confirm")
public ApiResponse<String> confirm(@RequestBody QrConfirmRequest request) {
String accessToken = qrLoginService.confirmQrCode(request.getToken(), request.getUserId());
return accessToken != null ? ApiResponse.success("登录确认成功") : ApiResponse.error("确认失败");
} @GetMapping("/status/{token}")
public ApiResponse<QrStatusResponse> pollStatus(@PathVariable String token) {
return ApiResponse.success(qrLoginService.pollStatus(token));
} @PostMapping("/refresh")
public ApiResponse<QrCreateResponse> refresh() {
return ApiResponse.success(qrLoginService.refreshQrCode());
}
}
讲解:Controller 层非常薄,只负责接收请求、调用 Service 并包装响应。每个接口都对应原理篇中的一个动作,命名清晰。注意 @RestController 和 @RequestMapping 的使用,所有接口统一前缀 /api/qrcode。
复制代码// QrLoginApplication.java
package com.example.qrlogin;import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplication
public class QrLoginApplication {
public static void main(String[] args) {
SpringApplication.run(QrLoginApplication.class, args);
}
}// GlobalExceptionHandler.java
package com.example.qrlogin.exception;import com.example.qrlogin.dto.ApiResponse;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ApiResponse<?> handleException(Exception e) {
// 生产环境建议记录日志,并返回更友好的提示
return ApiResponse.error("服务器内部错误:" + e.getMessage());
}
}
讲解:GlobalExceptionHandler 使用 @RestControllerAdvice 统一处理所有未捕获的异常,保证接口始终返回统一的 JSON 格式,避免前端收到不友好的错误堆栈。
| 接口 | 方法 | 说明 | 请求体 |
|---|---|---|---|
/api/qrcode/create | POST | PC 端获取二维码 Token | 无 |
/api/qrcode/scan | POST | 手机端扫码 | {token, userId} |
/api/qrcode/confirm | POST | 手机端确认 | {token, userId} |
/api/qrcode/status/{token} | GET | PC 端轮询状态 | 无 |
/api/qrcode/refresh | POST | 刷新二维码 | 无 |
讲解:这些接口构成了完整的扫码登录交互流程,顺序不能颠倒,状态转换由服务端控制。
扫码登录的安全性是开发时不可忽略的一环,以下是几个关键保障措施:
userId,确认时校验该用户与扫码时是否一致,避免“张冠李戴”。scan 和 confirm 接口增加限流(如 IP 限流、用户限流),可使用 Guava RateLimiter 或 Sentinel。扫码登录的核心在于临时令牌 + 状态机 + 轮询/推送。通过本文的讲解和完整的 Spring Boot 3.x 代码实现,相信你已经掌握了从原理到落地的全过程。
实际生产开发中,你还可以根据业务需求扩展:
最后,所有代码已整合成一个完整的 Spring Boot 3.x 项目,你只需修改 Redis 配置即可运行体验。如果这篇文章对你有帮助,欢迎点赞收藏,也欢迎在评论区交流你的实现思路!