登录 | 扫码登录

登录 | 扫码登录

2026年5月1日星期五·#实践笔记扫码登录/认证/WebSocket·5611 字 28 分钟··
简介

对比短轮询、长轮询、WebSocket、SSE 四种扫码登录方案,给出基于 WebSocket + Redis 的企业级实现及安全防御。

登录 | 扫码登录#

核心思路:PC 端生成带唯一标识的二维码,手机端扫码确认后,服务端更新 Redis 状态,PC 端通过 WebSocket 实时感知状态变更并获取 Token。评审重点:二维码状态机的 5 种状态转换是否完整、WebSocket 与长轮询的降级策略是否可靠、安全防御是否覆盖二维码劫持和重放攻击。


一、背景#

用户在 PC 端访问 Web 应用时,输入账号密码是最直接的登录方式。但在以下场景中,扫码登录体验更优:

场景密码登录的问题扫码登录的优势
公共电脑键盘记录器窃取密码无需输入密码,凭证不经过 PC
移动端已登录重复输入密码体验差手机一键确认,零输入
大屏设备虚拟键盘输入效率低扫码即登,3 秒完成
安全敏感场景密码可能泄露Token 在手机端签发,PC 端不接触凭证

扫码登录的本质是将手机端已有的登录态”转移”到 PC 端,而非重新认证。这要求手机端用户必须已登录——扫码确认时,手机端携带的 Token 证明身份,服务端据此为 PC 端签发新 Token。


二、扫码登录核心流程#

2.1 完整时序#

sequenceDiagram participant PC as PC浏览器 participant Server as 认证服务 participant Redis as Redis participant App as 手机APP Note over PC, App: Phase 1: 生成二维码 PC->>Server: POST /auth/qr-code/generate Server->>Server: 生成 qrCodeId (UUID) Server->>Redis: SET qr_code:{qrCodeId} {status:PENDING, ttl:120s} Server-->>PC: 返回 qrCodeId + 二维码图片URL Note over PC, App: Phase 2: PC端建立WebSocket连接 PC->>Server: WebSocket 连接 /ws/qr-code?qrCodeId=xxx Server->>Redis: GET qr_code:{qrCodeId} Redis-->>Server: status=PENDING Server-->>PC: 当前状态 PENDING Note over PC, App: Phase 3: 手机扫码 App->>Server: POST /auth/qr-code/scan {qrCodeId, accessToken} Server->>Redis: 校验 accessToken 有效性 Server->>Redis: GET qr_code:{qrCodeId} alt 二维码未过期 Server->>Redis: SET qr_code:{qrCodeId} {status:SCANNED, userId:xxx} Server-->>App: 返回确认页面信息(用户昵称、头像) Server->>PC: WebSocket 推送 SCANNED 状态 else 二维码已过期 Server-->>App: 返回二维码已过期 Server->>PC: WebSocket 推送 EXPIRED 状态 end Note over PC, App: Phase 4: 手机确认登录 App->>Server: POST /auth/qr-code/confirm {qrCodeId, accessToken} Server->>Redis: GET qr_code:{qrCodeId} Server->>Redis: SET qr_code:{qrCodeId} {status:CONFIRMED, userId:xxx, pcToken:xxx, pcRefreshToken:xxx} Server-->>App: 返回确认成功 Server->>PC: WebSocket 推送 CONFIRMED 状态 + pcToken Note over PC, App: Phase 5: PC端完成登录 PC->>PC: 存储 Token,跳转首页 Note over PC, App: 异常: 手机取消登录 App->>Server: POST /auth/qr-code/cancel {qrCodeId, accessToken} Server->>Redis: SET qr_code:{qrCodeId} {status:CANCELED} Server->>PC: WebSocket 推送 CANCELED 状态

2.2 二维码状态机#

二维码有 5 种状态,转换关系如下:

┌─────────────────────────────────────┐
│ 生成二维码 │
│ (PENDING) │
└──────────┬──────────────────────────┘
┌──────────▼──────────────────────────┐
┌─────┤ 等待扫码 │
│ │ (PENDING) │
│ └──┬──────────────────┬───────────────┘
│ │ │
│ ┌────▼─────┐ ┌─────▼──────┐
│ │ 超时 │ │ 手机扫码 │
│ │ (EXPIRED) │ │ (SCANNED) │
│ └──────────┘ └──┬───────┬──┘
│ │ │
│ ┌────▼──┐ ┌──▼────────┐
│ │ 确认 │ │ 取消 │
│ │(CONFIRMED)│(CANCELED) │
│ └────┬──┘ └───────────┘
│ │
└────────────────────────┘
(任何非PENDING状态
均为终态,不可逆)
状态含义可转换到触发条件
PENDING等待扫码SCANNED / EXPIRED手机扫码 / 超时
SCANNED已扫码待确认CONFIRMED / CANCELED / EXPIRED手机确认 / 手机取消 / 超时
CONFIRMED已确认终态,PC 端获取 Token
CANCELED已取消终态,PC 端提示取消
EXPIRED已过期终态,PC 端提示刷新

关键约束:状态只能单向流转,不可回退。CONFIRMED / CANCELED / EXPIRED 均为终态。


三、方案对比:PC 端如何感知状态变更#

PC 端生成二维码后,需要实时感知”手机已扫码/已确认/已取消”的状态变更。四种主流方案的对比如下:

3.1 方案总览#

维度短轮询长轮询WebSocketSSE
通信方向客户端→服务端客户端→服务端双向服务端→客户端
实时性取决于轮询间隔(1-5s 延迟)近实时(状态变更即返回)实时实时
服务端资源每次请求都查 Redis等待期间占用线程连接建立后无额外请求(NIO不占线程)同 WebSocket
连接复杂度无需维持连接无需维持连接需维持长连接 + 心跳需维持长连接
兼容性全平台全平台IE10+,移动端需注意IE 不支持
断线重连天然支持(下次轮询即可)需客户端重发请求需心跳 + 重连机制内置重连(EventSource)
防火墙/代理无问题部分代理提前返回可能被拦截可能被拦截
实现复杂度★☆☆★★☆★★★★★☆

3.2 资源消耗对比#

以 10,000 个并发等待扫码的 PC 端为例:

方案每秒请求数线程占用Redis 查询次数/秒带宽消耗
短轮询 (2s 间隔)5,000 QPS低(请求即释放)5,000 次高(频繁 HTTP 头)
长轮询 (30s 超时)~333 QPS中(hold 线程)~333 次
WebSocket0(仅心跳)低(NIO 不占线程)仅状态变更时极低
SSE0(仅心跳)仅状态变更时极低

3.3 方案选型结论#

┌──────────────────────────────────────────────────────────────────┐
│ 扫码登录方案选择决策树 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ 企业级首选:WebSocket │
│ └── 实时性最好,资源占用最低,体验最佳 │
│ │
│ 降级方案:长轮询 │
│ └── WebSocket 连接失败时自动降级,保证兼容性 │
│ │
│ 快速验证:短轮询 │
│ └── 实现简单,适合原型验证 │
│ │
│ 需兼容旧浏览器:短轮询 + 长轮询 │
│ └── 最差体验,最强兼容性 │
│ │
│ 企业级推荐:WebSocket 为主 + 长轮询降级 │
│ └── 优先 WebSocket,连接失败自动降级为长轮询 │
│ │
└──────────────────────────────────────────────────────────────────┘

本文选择 WebSocket 为主方案,长轮询为降级方案,原因:

  1. 实时性最好,状态变更立即推送
  2. 资源占用最低,NIO 不占用 Servlet 线程
  3. 用户体验最佳,无延迟感
  4. 长轮询降级保证兼容性

四、后端实现:基于 Spring Boot + Redis#

4.1 Maven 依赖配置#

pom.xml
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
<relativePath/>
</parent>
<dependencies>
<!-- WebSocket 支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- Redis 支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Web 支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- JWT 支持 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Swagger/OpenAPI -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.3.0</version>
</dependency>
</dependencies>

4.2 数据模型#

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class QrCodeStatus {
private String qrCodeId;
private QrCodeStatusEnum status;
private Long userId;
private String nickname;
private String avatar;
private String pcAccessToken;
private String pcRefreshToken;
private LocalDateTime createdAt;
private LocalDateTime expireAt;
}
public enum QrCodeStatusEnum {
PENDING(0, "等待扫码"),
SCANNED(1, "已扫码待确认"),
CONFIRMED(2, "已确认"),
CANCELED(3, "已取消"),
EXPIRED(4, "已过期");
private final int code;
private final String desc;
}

4.2 Redis 存储设计#

Key类型TTL说明
qr_code:{qrCodeId}Hash120s二维码状态数据
qr_code:scan_lock:{qrCodeId}String (NX)120s扫码操作分布式锁,防并发扫码

Hash 字段:

Field说明
status状态码(0-4)
userId扫码用户 ID
nickname扫码用户昵称
avatar扫码用户头像
pcAccessTokenPC 端访问 Token
pcRefreshTokenPC 端刷新 Token
createdAt创建时间戳

4.3 WebSocket 配置#

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
private final QrCodeWebSocketHandler qrCodeWebSocketHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(qrCodeWebSocketHandler, "/ws/qr-code")
.setAllowedOrigins("*");
}
}
@Component
public class QrCodeWebSocketHandler extends TextWebSocketHandler {
private final ConcurrentHashMap<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
private final RedisService redisService;
private static final String QR_CODE_KEY_PREFIX = "qr_code:";
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
String qrCodeId = getQrCodeId(session);
if (qrCodeId == null) {
session.close();
return;
}
sessions.put(qrCodeId, session);
QrCodeStatus current = getQrCodeStatusFromRedis(qrCodeId);
if (current != null) {
sendStatus(session, current);
}
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
String qrCodeId = getQrCodeId(session);
if (qrCodeId != null) {
sessions.remove(qrCodeId);
}
}
public void pushStatusUpdate(String qrCodeId, QrCodeStatus status) {
WebSocketSession session = sessions.get(qrCodeId);
if (session != null && session.isOpen()) {
sendStatus(session, status);
}
}
private void sendStatus(WebSocketSession session, QrCodeStatus status) {
try {
ObjectMapper mapper = new ObjectMapper();
session.sendMessage(new TextMessage(mapper.writeValueAsString(buildResponse(status))));
} catch (Exception e) {
e.printStackTrace();
}
}
private QrCodeStatusResponse buildResponse(QrCodeStatus status) {
return QrCodeStatusResponse.builder()
.qrCodeId(status.getQrCodeId())
.status(status.getStatus())
.userId(status.getUserId())
.nickname(status.getNickname())
.avatar(status.getAvatar())
.pcAccessToken(status.getPcAccessToken())
.pcRefreshToken(status.getPcRefreshToken())
.build();
}
private String getQrCodeId(WebSocketSession session) {
String query = session.getUri().getQuery();
if (query == null) return null;
for (String param : query.split("&")) {
String[] pair = param.split("=");
if ("qrCodeId".equals(pair[0])) {
return pair[1];
}
}
return null;
}
private QrCodeStatus getQrCodeStatusFromRedis(String qrCodeId) {
String key = QR_CODE_KEY_PREFIX + qrCodeId;
Map<String, String> hash = redisService.hGetAll(key);
if (hash == null || hash.isEmpty()) {
return null;
}
return QrCodeStatus.builder()
.qrCodeId(qrCodeId)
.status(QrCodeStatusEnum.fromCode(Integer.parseInt(hash.get("status"))))
.userId(hash.containsKey("userId") ? Long.parseLong(hash.get("userId")) : null)
.nickname(hash.get("nickname"))
.avatar(hash.get("avatar"))
.pcAccessToken(hash.get("pcAccessToken"))
.pcRefreshToken(hash.get("pcRefreshToken"))
.build();
}
}

4.4 生成二维码#

@Service
@RequiredArgsConstructor
@Slf4j
public class QrCodeLoginServiceImpl implements QrCodeLoginService {
private final RedisService redisService;
private final JwtUtils jwtUtils;
private final QrCodeWebSocketHandler webSocketHandler;
private static final long QR_CODE_TTL_SECONDS = 120;
private static final String QR_CODE_KEY_PREFIX = "qr_code:";
private static final String QR_CODE_SCAN_LOCK_PREFIX = "qr_code:scan_lock:";
@Override
public QrCodeGenerateResponse generate() {
String qrCodeId = UUID.randomUUID().toString().replace("-", "");
QrCodeStatus status = QrCodeStatus.builder()
.qrCodeId(qrCodeId)
.status(QrCodeStatusEnum.PENDING)
.createdAt(LocalDateTime.now())
.expireAt(LocalDateTime.now().plusSeconds(QR_CODE_TTL_SECONDS))
.build();
String key = QR_CODE_KEY_PREFIX + qrCodeId;
Map<String, String> hash = new HashMap<>();
hash.put("status", String.valueOf(QrCodeStatusEnum.PENDING.getCode()));
hash.put("createdAt", String.valueOf(System.currentTimeMillis()));
redisService.hPutAll(key, hash);
redisService.expire(key, QR_CODE_TTL_SECONDS, TimeUnit.SECONDS);
String qrCodeUrl = buildQrCodeUrl(qrCodeId);
return QrCodeGenerateResponse.builder()
.qrCodeId(qrCodeId)
.qrCodeUrl(qrCodeUrl)
.expireIn(QR_CODE_TTL_SECONDS)
.build();
}
private String buildQrCodeUrl(String qrCodeId) {
return "https://your-domain.com/scan?qrCodeId=" + qrCodeId;
}

4.5 手机扫码#

@Override
public QrCodeScanResponse scan(String qrCodeId, String accessToken) {
Long userId = jwtUtils.getUserIdAsLong(accessToken);
String lockKey = QR_CODE_SCAN_LOCK_PREFIX + qrCodeId;
Boolean locked = redisService.setIfAbsent(lockKey, String.valueOf(userId), 5, TimeUnit.SECONDS);
if (Boolean.FALSE.equals(locked)) {
throw new AuthException("二维码正在被其他设备处理,请稍后重试");
}
try {
String key = QR_CODE_KEY_PREFIX + qrCodeId;
Map<String, String> hash = redisService.hGetAll(key);
if (hash == null || hash.isEmpty()) {
QrCodeStatus expiredStatus = QrCodeStatus.builder()
.qrCodeId(qrCodeId)
.status(QrCodeStatusEnum.EXPIRED)
.build();
webSocketHandler.pushStatusUpdate(qrCodeId, expiredStatus);
throw new AuthException("二维码已过期,请刷新重试");
}
QrCodeStatusEnum currentStatus = QrCodeStatusEnum.fromCode(
Integer.parseInt(hash.get("status")));
if (currentStatus == QrCodeStatusEnum.SCANNED) {
Long existUserId = Long.parseLong(hash.get("userId"));
if (!existUserId.equals(userId)) {
throw new AuthException("该二维码已被其他用户扫描");
}
return QrCodeScanResponse.alreadyScanned();
}
if (currentStatus != QrCodeStatusEnum.PENDING) {
throw new AuthException("二维码状态异常: " + currentStatus.getDesc());
}
hash.put("status", String.valueOf(QrCodeStatusEnum.SCANNED.getCode()));
hash.put("userId", String.valueOf(userId));
SysUserApi user = remoteUserService.getUserById(userId);
hash.put("nickname", user.getNickName());
hash.put("avatar", user.getAvatar());
redisService.hPutAll(key, hash);
redisService.expire(key, 60, TimeUnit.SECONDS);
QrCodeStatus scannedStatus = QrCodeStatus.builder()
.qrCodeId(qrCodeId)
.status(QrCodeStatusEnum.SCANNED)
.userId(userId)
.nickname(user.getNickName())
.avatar(user.getAvatar())
.build();
webSocketHandler.pushStatusUpdate(qrCodeId, scannedStatus);
return QrCodeScanResponse.builder()
.qrCodeId(qrCodeId)
.nickname(user.getNickName())
.avatar(user.getAvatar())
.build();
} finally {
redisService.deleteObject(lockKey);
}
}

4.7 手机确认登录#

@Override
public QrCodeConfirmResponse confirm(String qrCodeId, String accessToken) {
Long userId = jwtUtils.getUserIdAsLong(accessToken);
String key = QR_CODE_KEY_PREFIX + qrCodeId;
Map<String, String> hash = redisService.hGetAll(key);
if (hash == null || hash.isEmpty()) {
QrCodeStatus expiredStatus = QrCodeStatus.builder()
.qrCodeId(qrCodeId)
.status(QrCodeStatusEnum.EXPIRED)
.build();
webSocketHandler.pushStatusUpdate(qrCodeId, expiredStatus);
throw new AuthException("二维码已过期");
}
QrCodeStatusEnum currentStatus = QrCodeStatusEnum.fromCode(
Integer.parseInt(hash.get("status")));
if (currentStatus != QrCodeStatusEnum.SCANNED) {
throw new AuthException("二维码状态异常,当前状态: " + currentStatus.getDesc());
}
Long scannedUserId = Long.parseLong(hash.get("userId"));
if (!scannedUserId.equals(userId)) {
throw new AuthException("确认用户与扫码用户不一致");
}
Map<String, Object> claims = new HashMap<>();
claims.put(SecurityConstants.USER_ID, userId);
claims.put(SecurityConstants.USER_NAME, hash.get("nickname"));
claims.put("token_type", "access");
String pcAccessToken = jwtUtils.createToken(claims);
String refreshToken = jwtUtils.createRefreshToken(userId);
String accessTokenKey = CacheConstants.CACHE_LOGIN_TOKEN + userId;
String refreshTokenKey = CacheConstants.CACHE_LOGIN_REFRESH + userId;
redisService.setSetCacheObject(accessTokenKey, pcAccessToken);
redisService.expire(accessTokenKey, SecurityConstants.TOKEN_EXPIRE, TimeUnit.MINUTES);
redisService.setSetCacheObject(refreshTokenKey, refreshToken);
redisService.expire(refreshTokenKey, SecurityConstants.REFRESH_TOKEN_EXPIRE, TimeUnit.DAYS);
hash.put("status", String.valueOf(QrCodeStatusEnum.CONFIRMED.getCode()));
hash.put("pcAccessToken", pcAccessToken);
hash.put("pcRefreshToken", refreshToken);
redisService.hPutAll(key, hash);
QrCodeStatus confirmedStatus = QrCodeStatus.builder()
.qrCodeId(qrCodeId)
.status(QrCodeStatusEnum.CONFIRMED)
.userId(userId)
.nickname(hash.get("nickname"))
.avatar(hash.get("avatar"))
.pcAccessToken(pcAccessToken)
.pcRefreshToken(refreshToken)
.build();
webSocketHandler.pushStatusUpdate(qrCodeId, confirmedStatus);
redisService.deleteObject(key);
return QrCodeConfirmResponse.success(qrCodeId);
}

4.8 手机取消登录#

@Override
public void cancel(String qrCodeId, String accessToken) {
Long userId = jwtUtils.getUserIdAsLong(accessToken);
String key = QR_CODE_KEY_PREFIX + qrCodeId;
Map<String, String> hash = redisService.hGetAll(key);
if (hash == null || hash.isEmpty()) {
QrCodeStatus expiredStatus = QrCodeStatus.builder()
.qrCodeId(qrCodeId)
.status(QrCodeStatusEnum.EXPIRED)
.build();
webSocketHandler.pushStatusUpdate(qrCodeId, expiredStatus);
return;
}
QrCodeStatusEnum currentStatus = QrCodeStatusEnum.fromCode(
Integer.parseInt(hash.get("status")));
if (currentStatus != QrCodeStatusEnum.SCANNED) {
return;
}
Long scannedUserId = Long.parseLong(hash.get("userId"));
if (!scannedUserId.equals(userId)) {
throw new AuthException("取消用户与扫码用户不一致");
}
hash.put("status", String.valueOf(QrCodeStatusEnum.CANCELED.getCode()));
redisService.hPutAll(key, hash);
QrCodeStatus canceledStatus = QrCodeStatus.builder()
.qrCodeId(qrCodeId)
.status(QrCodeStatusEnum.CANCELED)
.userId(userId)
.build();
webSocketHandler.pushStatusUpdate(qrCodeId, canceledStatus);
}

4.9 长轮询降级实现#

@Override
public QrCodeStatusResponse getStatus(String qrCodeId, long timeoutSeconds) {
long startTime = System.currentTimeMillis();
long timeoutMillis = TimeUnit.SECONDS.toMillis(Math.min(timeoutSeconds, 25));
while (System.currentTimeMillis() - startTime < timeoutMillis) {
QrCodeStatus status = getQrCodeStatusFromRedis(qrCodeId);
if (status == null) {
return QrCodeStatusResponse.expired(qrCodeId);
}
if (status.getStatus() != QrCodeStatusEnum.PENDING) {
return buildResponse(status);
}
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return buildResponse(status);
}
}
QrCodeStatus status = getQrCodeStatusFromRedis(qrCodeId);
if (status == null) {
return QrCodeStatusResponse.expired(qrCodeId);
}
return buildResponse(status);
}
private QrCodeStatusResponse buildResponse(QrCodeStatus status) {
return QrCodeStatusResponse.builder()
.qrCodeId(status.getQrCodeId())
.status(status.getStatus())
.userId(status.getUserId())
.nickname(status.getNickname())
.avatar(status.getAvatar())
.pcAccessToken(status.getPcAccessToken())
.pcRefreshToken(status.getPcRefreshToken())
.build();
}
private QrCodeStatus getQrCodeStatusFromRedis(String qrCodeId) {
String key = QR_CODE_KEY_PREFIX + qrCodeId;
Map<String, String> hash = redisService.hGetAll(key);
if (hash == null || hash.isEmpty()) {
return null;
}
return QrCodeStatus.builder()
.qrCodeId(qrCodeId)
.status(QrCodeStatusEnum.fromCode(Integer.parseInt(hash.get("status"))))
.userId(hash.containsKey("userId") ? Long.parseLong(hash.get("userId")) : null)
.nickname(hash.get("nickname"))
.avatar(hash.get("avatar"))
.pcAccessToken(hash.get("pcAccessToken"))
.pcRefreshToken(hash.get("pcRefreshToken"))
.build();
}
}

4.10 Controller 层#

@RestController
@RequestMapping("/auth/qr-code")
@RequiredArgsConstructor
@Tag(name = "扫码登录")
public class QrCodeLoginController {
private final QrCodeLoginService qrCodeLoginService;
@Operation(summary = "生成二维码")
@PostMapping("/generate")
public R<QrCodeGenerateResponse> generate() {
return R.ok(qrCodeLoginService.generate());
}
@Operation(summary = "查询二维码状态(长轮询降级)")
@GetMapping("/status")
public R<QrCodeStatusResponse> getStatus(
@RequestParam String qrCodeId,
@RequestParam(defaultValue = "25") long timeout) {
return R.ok(qrCodeLoginService.getStatus(qrCodeId, timeout));
}
@Operation(summary = "手机扫码")
@PostMapping("/scan")
public R<QrCodeScanResponse> scan(
@RequestParam String qrCodeId,
@RequestHeader(SecurityConstants.AUTHORIZATION_HEADER) String authorization) {
String accessToken = authorization.replace(SecurityConstants.TOKEN_PREFIX, "");
return R.ok(qrCodeLoginService.scan(qrCodeId, accessToken));
}
@Operation(summary = "手机确认登录")
@PostMapping("/confirm")
public R<QrCodeConfirmResponse> confirm(
@RequestParam String qrCodeId,
@RequestHeader(SecurityConstants.AUTHORIZATION_HEADER) String authorization) {
String accessToken = authorization.replace(SecurityConstants.TOKEN_PREFIX, "");
return R.ok(qrCodeLoginService.confirm(qrCodeId, accessToken));
}
@Operation(summary = "手机取消登录")
@PostMapping("/cancel")
public R<Void> cancel(
@RequestParam String qrCodeId,
@RequestHeader(SecurityConstants.AUTHORIZATION_HEADER) String authorization) {
String accessToken = authorization.replace(SecurityConstants.TOKEN_PREFIX, "");
qrCodeLoginService.cancel(qrCodeId, accessToken);
return R.ok();
}
}

4.11 Gateway 白名单#

security:
ignore:
whites:
- /api/auth/qr-code/generate
- /api/auth/qr-code/status
- /ws/qr-code/**

/scan/confirm/cancel 不在白名单中——手机端必须携带有效 Token 才能操作。


五、前端实现#

5.1 PC 端:二维码展示 + WebSocket#

import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { qrCodeApi } from '@/api/auth'
type QrCodeStatus = 'PENDING' | 'SCANNED' | 'CONFIRMED' | 'CANCELED' | 'EXPIRED'
interface QrCodeStatusResponse {
qrCodeId: string
status: QrCodeStatus
userId?: number
nickname?: string
avatar?: string
pcAccessToken?: string
pcRefreshToken?: string
}
export function useQrCodeLogin() {
const router = useRouter()
const userStore = useUserStore()
const qrCodeId = ref('')
const qrCodeUrl = ref('')
const status = ref<QrCodeStatus>('PENDING')
const scannedUser = ref<{ nickname: string; avatar: string } | null>(null)
const countdown = ref(0)
const loading = ref(false)
const useFallback = ref(false)
let ws: WebSocket | null = null
let countdownTimer: ReturnType<typeof setInterval> | null = null
let reconnectAttempts = 0
const MAX_RECONNECT = 3
let pollTimer: ReturnType<typeof setTimeout> | null = null
async function generateQrCode() {
loading.value = true
try {
const res = await qrCodeApi.generate()
qrCodeId.value = res.data.qrCodeId
qrCodeUrl.value = res.data.qrCodeUrl
countdown.value = res.data.expireIn
status.value = 'PENDING'
scannedUser.value = null
useFallback.value = false
reconnectAttempts = 0
startCountdown()
connectWs()
} finally {
loading.value = false
}
}
function connectWs() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${protocol}//${window.location.host}/ws/qr-code?qrCodeId=${qrCodeId.value}`
ws = new WebSocket(wsUrl)
ws.onopen = () => {
reconnectAttempts = 0
}
ws.onmessage = (event) => {
const data: QrCodeStatusResponse = JSON.parse(event.data)
handleStatusUpdate(data)
}
ws.onclose = () => {
if (status.value === 'CONFIRMED' || status.value === 'CANCELED' || status.value === 'EXPIRED') {
return
}
if (reconnectAttempts < MAX_RECONNECT) {
reconnectAttempts++
setTimeout(() => connectWs(), 2000 * reconnectAttempts)
} else {
fallbackToPolling()
}
}
ws.onerror = () => {
ws?.close()
}
}
function fallbackToPolling() {
useFallback.value = true
startPolling()
}
async function startPolling() {
if (status.value === 'CONFIRMED' || status.value === 'CANCELED' || status.value === 'EXPIRED') {
return
}
try {
const res = await qrCodeApi.getStatus(qrCodeId.value, 25)
handleStatusUpdate(res.data)
} catch {
} finally {
if (useFallback.value && status.value === 'PENDING') {
pollTimer = setTimeout(() => startPolling(), 2000)
}
}
}
function handleStatusUpdate(data: QrCodeStatusResponse) {
status.value = data.status
switch (data.status) {
case 'SCANNED':
scannedUser.value = { nickname: data.nickname!, avatar: data.avatar! }
break
case 'CONFIRMED':
handleLoginSuccess(data)
break
case 'CANCELED':
case 'EXPIRED':
break
}
}
function handleLoginSuccess(data: QrCodeStatusResponse) {
if (data.pcAccessToken) {
document.cookie = `access_token=${data.pcAccessToken}; path=/; max-age=1800; SameSite=Lax; ${window.location.protocol === 'https:' ? 'Secure;' : ''}`
}
if (data.pcRefreshToken) {
document.cookie = `refresh_token=${data.pcRefreshToken}; path=/; max-age=2592000; SameSite=Lax; ${window.location.protocol === 'https:' ? 'Secure;' : ''}`
}
userStore.setUserInfo({ accessToken: data.pcAccessToken! })
router.push('/dashboard')
}
function startCountdown() {
if (countdownTimer) clearInterval(countdownTimer)
countdownTimer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
status.value = 'EXPIRED'
cleanup()
}
}, 1000)
}
function cleanup() {
if (ws) {
ws.close()
ws = null
}
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
}
function refreshQrCode() {
cleanup()
generateQrCode()
}
onMounted(() => generateQrCode())
onUnmounted(() => cleanup())
return {
qrCodeId, qrCodeUrl, status, scannedUser, countdown, loading,
useFallback, refreshQrCode
}
}

5.2 PC 端:Vue 组件#

<template>
<div class="qr-login">
<div v-if="status === 'PENDING'" class="qr-pending">
<QrCodeCanvas :value="qrCodeUrl" :size="200" />
<p>请使用手机 APP 扫描二维码登录</p>
<span class="countdown">{{ countdown }}s 后过期</span>
<span v-if="useFallback" class="fallback">当前使用降级连接</span>
</div>
<div v-else-if="status === 'SCANNED'" class="qr-scanned">
<Avatar :src="scannedUser?.avatar" :size="48" />
<p>{{ scannedUser?.nickname }} 已扫码</p>
<p>请在手机上确认登录</p>
</div>
<div v-else-if="status === 'CONFIRMED'" class="qr-confirmed">
<CheckCircleFilled style="font-size: 48px; color: #52c41a" />
<p>登录成功,正在跳转...</p>
</div>
<div v-else-if="status === 'CANCELED'" class="qr-canceled">
<CloseCircleFilled style="font-size: 48px; color: #ff4d4f" />
<p>登录已取消</p>
<Button type="primary" @click="refreshQrCode">重新扫码</Button>
</div>
<div v-else-if="status === 'EXPIRED'" class="qr-expired">
<QrCodeCanvas :value="qrCodeUrl" :size="200" level="L" :fg-color="#d9d9d9" />
<p>二维码已过期</p>
<Button type="primary" @click="refreshQrCode">刷新二维码</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { QrCodeCanvas } from 'qrcode.react'
import { useQrCodeLogin } from '@/composables/useQrCodeLogin'
const {
qrCodeUrl, status, scannedUser, countdown, loading, useFallback, refreshQrCode
} = useQrCodeLogin()
</script>
<style scoped>
.fallback {
font-size: 12px;
color: #999;
margin-top: 8px;
}
</style>

5.3 手机端:扫码 + 确认流程#

export function useScanLogin() {
const router = useRouter()
const confirmLoading = ref(false)
const scanResult = ref<QrCodeScanResponse | null>(null)
async function scanQrCode(qrCodeId: string) {
try {
const res = await qrCodeApi.scan(qrCodeId)
scanResult.value = res.data
} catch (error: any) {
if (error.response?.data?.msg?.includes('已过期')) {
showToast('二维码已过期,请在 PC 端刷新')
} else if (error.response?.data?.msg?.includes('已被其他用户')) {
showToast('该二维码已被其他用户扫描')
} else {
showToast('扫码失败,请重试')
}
router.back()
}
}
async function confirmLogin(qrCodeId: string) {
confirmLoading.value = true
try {
await qrCodeApi.confirm(qrCodeId)
showToast('登录成功')
router.back()
} catch {
showToast('确认失败,请重试')
} finally {
confirmLoading.value = false
}
}
async function cancelLogin(qrCodeId: string) {
try {
await qrCodeApi.cancel(qrCodeId)
router.back()
} catch {
router.back()
}
}
return { scanResult, confirmLoading, scanQrCode, confirmLogin, cancelLogin }
}

5.4 手机端:确认页面组件#

<template>
<div class="scan-confirm">
<div class="user-info">
<Avatar :src="scanResult?.avatar" :size="64" />
<p class="nickname">{{ scanResult?.nickname }}</p>
</div>
<div class="confirm-text">
<p>确认登录 Web 端?</p>
</div>
<div class="actions">
<Button block @click="cancelLogin(qrCodeId)">取消</Button>
<Button block type="primary" :loading="confirmLoading" @click="confirmLogin(qrCodeId)">
确认登录
</Button>
</div>
<p class="tip">确认后,PC 端将自动登录您的账号</p>
</div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useScanLogin } from '@/composables/useScanLogin'
const route = useRoute()
const qrCodeId = route.query.qrCodeId as string
const { scanResult, confirmLoading, scanQrCode, confirmLogin, cancelLogin } = useScanLogin()
onMounted(() => scanQrCode(qrCodeId))
</script>

六、安全防御#

6.1 威胁模型#

攻击类型攻击方式影响
二维码劫持攻击者生成自己的二维码,诱导用户扫描用户登录到攻击者会话
二维码替换攻击者替换页面上的二维码图片同上
重放攻击截获确认请求重放重复登录
暴力扫码脚本遍历 qrCodeId 尝试扫码占用资源,可能撞到有效二维码
CSRF 扫码跨站请求触发扫码/确认非授权操作
中间人攻击HTTP 明文截获 TokenToken 泄露
WebSocket 劫持劫持 WebSocket 连接获取状态推送非授权获取状态变更

6.2 防御措施#

1)二维码劫持防御

二维码 URL 中不包含任何敏感信息,仅包含 qrCodeId。攻击者即使生成自己的二维码,也无法获取受害者的 Token——因为 Token 是服务端根据扫码用户的身份签发的,与二维码本身无关。

攻击者生成 qrCodeId=attacker123 → 诱导用户扫描
→ 用户扫码后,服务端将 attacker123 的状态设为 SCANNED + userId=受害者
→ 攻击者 PC 端 WebSocket 连接 attacker123 → 获取到受害者的 Token
防御:Token 不直接写入 Redis Hash,而是在状态变更时
临时生成并立即通过 WebSocket 推送,推送后立即删除
同时校验 WebSocket 连接的同源性

关键防御:WebSocket 连接校验 Origin,确保只有同源页面能连接。

@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
String origin = session.getHandshakeHeaders().getOrigin();
if (!isValidOrigin(origin)) {
session.close();
return;
}
// ...
}

2)暴力扫码防御

qrCodeId 使用 UUID v4(128 位随机),暴力遍历的概率极低。额外限制:

@PostMapping("/scan")
@RateLimit(resource = "auth:qr-code:scan", key = "#accessToken", count = 10, timeUnit = TimeUnit.MINUTES)
public R<QrCodeScanResponse> scan(...) { }

3)分布式锁防并发扫码

同一个 qrCodeId 同时只能被一个用户扫码。使用 Redis SET NX 实现分布式锁:

String lockKey = QR_CODE_SCAN_LOCK_PREFIX + qrCodeId;
Boolean locked = redisService.setIfAbsent(lockKey, String.valueOf(userId), 5, TimeUnit.SECONDS);
if (Boolean.FALSE.equals(locked)) {
throw new AuthException("二维码正在被其他设备处理");
}

4)Token 不经过手机端传输

确认登录时,服务端直接为 PC 端签发 Token,Token 通过 WebSocket 推送返回。手机端只接收”确认成功/失败”的结果,不接触 PC 端的 Token。

5)WSS 强制

生产环境 WebSocket 必须使用 wss://,所有接口必须走 HTTPS,防止中间人截获 Token。

6.3 安全措施汇总#

威胁防御措施防御效果
二维码劫持Origin 校验 + Token 即时推送即时删除✅ 攻击者无法获取他人 Token
二维码替换二维码由服务端生成,前端不缓存✅ 每次刷新都是新二维码
重放攻击HTTPS + 请求签名 + qrCodeId 一次性✅ 确认后二维码状态不可逆
暴力扫码UUID v4 + RateLimit + 分布式锁✅ 遍历空间 2^128,限流 10次/分钟
CSRF 扫码手机端需 Authorization Header✅ 跨站请求无法携带 Token
中间人攻击WSS + HTTPS + Secure Cookie✅ 传输加密
WebSocket 劫持Origin 校验 + 连接 ID 绑定✅ 非同源连接被拒绝

七、踩坑点 & 注意事项#

7.1 WebSocket 连接断开后状态丢失#

问题:用户网络抖动导致 WebSocket 断开,重连后无法获取之前的状态。

解决:WebSocket 连接建立后,立即从 Redis 读取当前状态并推送:

@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
String qrCodeId = getQrCodeId(session);
// ... 校验逻辑 ...
sessions.put(qrCodeId, session);
QrCodeStatus current = getQrCodeStatusFromRedis(qrCodeId);
if (current != null) {
sendStatus(session, current);
}
}

7.2 二维码过期但手机端还在确认#

问题:用户在二维码即将过期时扫码,确认请求到达时二维码已过期。

解决:扫码时延长 TTL,给确认操作留出时间窗口:

@Override
public QrCodeScanResponse scan(String qrCodeId, String accessToken) {
// ... 校验逻辑 ...
// 扫码后延长 TTL 至 60 秒(给确认操作留时间)
redisService.expire(QR_CODE_KEY_PREFIX + qrCodeId, 60, TimeUnit.SECONDS);
// ...
}

7.3 用户扫码后不确认也不取消#

问题:用户扫码后关闭了手机 APP,二维码停留在 SCANNED 状态,PC 端一直等待。

解决:SCANNED 状态也设置超时(60 秒),超时后自动变为 EXPIRED:

// 扫码时设置 60 秒 TTL
redisService.expire(QR_CODE_KEY_PREFIX + qrCodeId, 60, TimeUnit.SECONDS);

Redis Key 过期时,通过 Keyspace Notification 触发状态推送:

@Component
public class QrCodeExpireListener {
private final QrCodeWebSocketHandler webSocketHandler;
public QrCodeExpireListener(QrCodeWebSocketHandler webSocketHandler) {
this.webSocketHandler = webSocketHandler;
}
public void onQrCodeExpire(String qrCodeId) {
QrCodeStatus expiredStatus = QrCodeStatus.builder()
.qrCodeId(qrCodeId)
.status(QrCodeStatusEnum.EXPIRED)
.build();
webSocketHandler.pushStatusUpdate(qrCodeId, expiredStatus);
}
}

7.4 PC 端刷新页面后丢失二维码#

问题:用户刷新页面,qrCodeId 丢失,无法继续连接。

解决:将 qrCodeId 存入 sessionStorage:

function persistQrCodeId(id: string) {
sessionStorage.setItem('pendingQrCodeId', id)
}
function restoreQrCodeId(): string | null {
const id = sessionStorage.getItem('pendingQrCodeId')
if (id) {
sessionStorage.removeItem('pendingQrCodeId')
}
return id
}
function generateQrCode() {
const savedId = restoreQrCodeId()
if (savedId) {
qrCodeId.value = savedId
// 重新建立连接
} else {
// 生成新二维码
}
}

页面加载时先检查 sessionStorage,如果有未完成的 qrCodeId,继续连接而非重新生成。

7.5 WebSocket 被防火墙/代理拦截#

问题:企业内网防火墙拦截 WebSocket 连接。

解决:长轮询降级机制,WebSocket 连接失败 3 次后自动降级为长轮询:

ws.onclose = () => {
if (status.value === 'CONFIRMED' || status.value === 'CANCELED' || status.value === 'EXPIRED') {
return
}
if (reconnectAttempts < MAX_RECONNECT) {
reconnectAttempts++
setTimeout(() => connectWs(), 2000 * reconnectAttempts)
} else {
fallbackToPolling()
}
}

八、生产环境部署清单#

□ 后端
□ Gateway 白名单添加 /api/auth/qr-code/generate、/api/auth/qr-code/status、/ws/qr-code/**
□ /scan、/confirm、/cancel 不在白名单中(需 Token)
□ WebSocket 配置 Origin 校验
□ Redis Keyspace Notification 开启(notify-keyspace-events Eg$)
□ RateLimit 配置:scan 接口 10 次/分钟/用户
□ WSS + HTTPS 强制
□ 前端 PC 端
□ 二维码使用 HTTPS URL
□ WebSocket 使用 wss://
□ 长轮询降级逻辑(WebSocket 失败 3 次后降级)
□ sessionStorage 保存 qrCodeId 防刷新丢失
□ 过期倒计时 UI 提示
□ 降级状态显示(当前使用降级连接)
□ 前端手机端
□ 扫码使用系统相机或 APP 内扫码组件
□ 确认页面展示 PC 端信息(如"Windows Chrome")
□ 确认/取消操作需 Authorization Header
□ 安全
□ 二维码 URL 不含敏感信息
□ PC 端 Token 不经过手机端
□ 分布式锁防并发扫码
□ WebSocket Origin 校验
□ Cookie Secure + SameSite=Lax
□ 监控
□ 二维码生成量监控
□ 扫码→确认转化率监控
□ WebSocket 连接数监控
□ 长轮询降级比例监控
□ Redis Key 过期事件监控

九、方案对比总结#

维度短轮询长轮询WebSocket
实时性1-5s 延迟近实时实时
服务端压力高(5,000 QPS/万用户)低(~333 QPS/万用户)最低(仅状态变更时)
实现复杂度
断线恢复天然支持客户端重发需心跳+重连+即时状态推送
代理/防火墙无问题部分代理提前返回可能被拦截
适用规模< 1,000 并发< 5,000 并发任意规模
推荐场景快速验证降级方案企业级首选

核心判断:WebSocket 实时性最好、资源占用最低,应作为首选方案。但需要配套长轮询降级机制,保证在 WebSocket 被拦截的场景下仍能正常使用。


参考资料#


如果这篇文章对你有帮助,欢迎点赞收藏。有问题欢迎评论区交流。

登录 | 扫码登录
https://blog1.z2m.store/posts/projects/projects-qrcode-login-java-comparison/
作者
深漂小鱼
发布于
2026-05-01
许可协议
CC BY-NC-SA 4.0

评论区

看板娘
公告
友链互换友链

正在招募技术类博客友链,要求原创、稳定更新。点击了解更多。

查看详情
维护服务器升级

本周日凌晨 2:00-4:00 进行服务器维护,期间站点可能短暂无法访问。

欢迎关于我的介绍

欢迎来到我的博客,我是深耕java、python和react技术开发。热爱技术、持续学习,欢迎同好交流探讨,也欢迎大佬互换友链。

查看详情
音乐
封面

音乐

暂未播放

0:00
0:00
暂无歌词
标签
# 认证2# Umami1# 部署1# Vercel1# 登录1# 安全1# 扫码登录1# WebSocket1
目录
工具

隐私政策

更新日期: 2026 年 7 月 15 日
生效日期: 2026 年 7 月 15 日

适用范围#

本政策适用于 MmzMing 的博客(以下简称“本站”)。本站是个人博客,用于发布和分享内容;不提供账户注册、支付、定位或广告投放服务。访问本站、发表文章评论或在留言板留言前,请阅读本政策。

信息收集与使用#

本站只在提供内容、评论和留言功能,以及维护站点安全所需的范围内处理信息。

  • 访问与统计信息:访问页面时,统计服务可能处理访问时间、页面地址、来源页、浏览器和设备相关信息,用于了解内容访问情况、排查故障和改进站点。
  • 评论信息:使用文章评论功能时,Waline 可能处理您主动提交的昵称、邮箱、站点链接和评论内容;还可能处理 IP 地址等必要信息,用于防止垃圾评论、滥用和维护服务安全。评论内容、昵称和站点链接(如填写)可能公开展示在文章下方;邮箱不会公开展示。
  • 留言信息:留言板使用 Waline /guestbook/ 频道。Waline 可能处理您主动提交的昵称、可选邮箱、站点链接、留言内容和图片,以及浏览器、操作系统、IP 地址等必要的反滥用信息。默认情况下,留言图片以内嵌数据随留言提交;如站点维护者配置了远程图片上传接口,图片会先发送至该接口并在留言中保存返回的图片地址。留言内容、昵称、图片和站点链接(如填写)可能公开展示;邮箱和 IP 地址不会在留言板公开展示。
  • AI 对话信息:使用 AI 搜索时,本站会处理您提交的问题以及最近 6 条对话历史,用于检索博客内容并生成回答。问题和对话历史会发送至 ModelScope,或在第三方接口不可用时由 Cloudflare Workers AI 处理。请勿在 AI 对话中提交密码、Token、身份证件、联系方式或其他敏感信息。

请不要在评论或留言中提交身份证件、银行卡、住址、密码或其他不必要的敏感个人信息。

第三方服务#

为实现本站功能,以下第三方会在各自服务范围内处理相关数据:

  • Umami:用于匿名化的网站访问统计和出站链接点击统计,帮助我了解本站的使用情况。
  • Waline:用于文章评论、留言板及访问量统计。服务会按照其自身规则处理您在评论或留言时提交的信息及必要的反滥用信息。
  • Cloudflare:为本站提供静态资源分发、AI Worker、Vectorize 和相关基础设施。留言板不使用项目 Worker 或 KV 存储。
  • ModelScope:为 AI 搜索提供文本向量和对话模型服务,会处理您提交的问题及发送给模型的最近对话历史。
  • Cloudflare Workers AI:在未配置第三方 AI 接口时提供文本向量和对话模型服务,并处理相同的 AI 请求数据。
  • unpkg:用于加载 Waline 的前端脚本、样式和表情资源;请求这些资源时,您的浏览器会与该服务建立连接。

第三方服务可能有独立的隐私政策和数据保存规则。请在使用相关功能前查阅其规则;本站无法控制其独立的数据处理活动。

本站主要使用浏览器本地存储(Local Storage 或 Session Storage)保存使用偏好,例如主题颜色、明暗模式、文章列表视图和音乐播放设置。留言板会在本地保存匿名资料、未发送草稿和登录状态,以便恢复输入与会话;管理员登录状态仅保存在当前会话。AI 搜索的会话标题和完整对话也会保存在当前浏览器的 Local Storage 中,最长保存 7 天;您可以在 AI 面板中使用“清空全部会话”立即删除这些数据。

本站不主动设置用于广告定向的第一方 Cookie。评论、统计或资源服务可能按照其自身规则使用 Cookie 或类似技术。您可以通过浏览器设置查看、删除或限制 Cookie 和本地存储;清除后,部分偏好或互动状态可能会恢复为默认值,评论功能也可能受到影响。

信息公开、保存与安全#

评论和留言属于公开互动内容,提交后可能被搜索引擎收录、被他人引用或在缓存中短暂保留。请谨慎决定发布内容。除非您提出删除请求、内容违反规则或法律法规另有要求,公开内容会持续保留以维持讨论上下文。

本站会采取合理措施保护数据安全,包括使用 HTTPS、输入校验、内容转义和访问频率限制。但互联网传输和第三方服务均无法保证绝对安全,请理解并自行承担公开发布信息的相应风险。

你的权利#

你可以通过 784774835@qq.com 联系我,申请查询、更正或删除由本站直接保存的评论、留言或相关公开内容。为保护他人权益,请在请求中提供足以定位内容的信息,并说明你与该内容的关系;必要时可能需要进行合理核验。

对于由 Waline、Umami、Cloudflare 或 unpkg 独立处理的数据,你也可以直接向对应服务提供方行使相关权利。删除公开评论或留言后,第三方缓存、搜索引擎索引或他人转载的副本可能无法立即同步删除。

未成年人条款#

未满 14 周岁的未成年人应在监护人同意和指导下使用本站的评论、留言等互动功能。监护人如发现未成年人未经同意提交了个人信息,可通过上述联系方式与我联系,我会在合理范围内协助处理。

政策更新与联系#

我可能因本站功能或适用规则变化更新本政策,更新后的版本将在本站公布并标明日期。继续使用相关功能即表示你已阅读并理解更新后的政策。

如对本政策或数据处理有疑问,请联系 784774835@qq.com

用户协议

更新日期: 2026 年 5 月 19 日
生效日期: 2026 年 5 月 19 日

适用范围#

本协议适用于你访问 MmzMing 的博客,以及使用文章评论、留言板等互动功能的行为。继续浏览本站或提交评论、留言,即表示你已阅读、理解并同意遵守本协议及本站的隐私政策。

评论及留言规则#

请在交流中保持友善、理性和尊重。你不得利用本站发布、传播或实施以下行为:

  • 发布任何违反中华人民共和国法律法规的内容。
  • 发布任何侵犯他人合法权益的内容,包括但不限于隐私、名誉、肖像、著作权、商标权和其他知识产权。
  • 恶意攻击、辱骂、骚扰、威胁、歧视其他用户或任何第三方。
  • 发布垃圾广告、恶意推广、刷屏、灌水,或与讨论主题明显无关的重复内容。
  • 利用本站进行网络诈骗、钓鱼、传播恶意软件,或发布可能危害网络和信息安全的内容。
  • 绕越或试图绕越本站的审核、限流、封禁等管理措施。
  • 冒充他人、伪造身份,或收集、公开他人的个人信息。

内容与访问管理#

你应对自己发布的评论和留言负责,并保证拥有发布该等内容所需的合法权利。论坛管理员有权在不另行通知的情况下删除违规内容、限制或封禁违规账号,或限制其继续使用本站互动功能。

如发现涉嫌违法犯罪、严重侵权或危及本站安全的内容,本站可保留相关记录,并在法律法规要求或必要时向有关部门提供协助。对管理措施有疑问时,可通过文末联系方式说明情况;本站会结合实际情况处理,但不承诺恢复已删除内容或访问权限。

知识产权与内容授权#

本站原创文章、页面设计和其他受保护内容的权利归作者或权利人所有。未经授权,请勿复制、转载、镜像或用于商业用途;法律法规允许的合理使用除外。

你发布评论或留言时,授予本站为展示、存储、备份、审核、删除和维护互动功能所必需的非独占、免费的使用许可。该许可不改变你对原创内容依法享有的权利。

免责声明#

本站内容仅用于个人记录、学习交流和一般信息参考,不构成任何专业意见、承诺或担保。你应结合自身情况独立判断,并对据此采取的行动负责。

评论、留言和外部链接中的内容由其发布者或运营者负责,不代表本站立场。本站会在合理范围内处理明显违规内容,但不保证所有内容均及时发现,也不对第三方网站的可用性、内容、安全性或隐私实践承担责任。

因网络故障、不可抗力、第三方服务异常、维护升级或超出合理控制范围的原因导致本站暂时无法访问、内容延迟或数据丢失的,本站会尽力恢复,但不承担由此产生的间接损失。

未成年人条款#

未满 14 周岁的未成年人应在监护人同意和指导下使用评论、留言等互动功能。监护人应协助未成年人理解本协议,并对其使用行为进行必要的引导。

其他条款#

我可以根据本站功能、管理需要或法律法规变化更新本协议,更新后的版本将在本站公布并标明日期。继续使用本站即视为接受更新后的协议。

本协议的订立、执行和解释适用中华人民共和国法律。因本协议或使用本站产生争议时,双方应先友好协商;协商不成的,依法向有管辖权的人民法院解决。

如对本协议或内容管理有疑问,请联系 784774835@qq.com