登录 | 扫码登录 | 对比文档

5615 字
28 分钟
登录 | 扫码登录 | 对比文档

登录 | 扫码登录 | 对比文档#

核心思路: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://tblog.mmzhiku.xyz/posts/projects/projects-qrcode-login-java-comparison/
作者
MmzMing
发布于
2026-05-01
许可协议
CC BY-NC-SA 4.0

评论区

看板娘
公告
友链 互换友链

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

查看详情
维护 服务器升级

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

欢迎 关于我的介绍

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

查看详情
音乐
封面

音乐

暂未播放

0:00
0:00
暂无歌词
标签
# AI 6 # 认证 5 # 安全 4 # 登录 3 # Skill 2 # Redis 2 # Bitmap 2 # 部署 2 # Java 2 # 并发编程 2 # 性能优化 2 # 前端 1 # 博客 1 # Prompt 1 # 工作流 1 # RAG 1 # Cloudflare 1 # 缓存设计 1 # 高性能 1 # Bot 1 # Umami 1 # Vercel 1 # 线程池 1 # 虚拟线程 1 # 分布式 1 # JWT 1 # OAuth2 1 # MinIO 1 # 文件存储 1 # 扫码登录 1 # WebSocket 1 # Agent 1 # Oracle 1 # 数据库 1
目录

隐私政策

更新日期: 2026/5/19
生效日期: 2026/5/19

导言#

MmzMing的知识库 是一款由 MmzMing(以下简称“我们”)提供的产品。您在使用我们的服务时,我们可能会收集和使用您的相关信息。我们希望通过本《隐私政策》向您说明,在使用我们的服务时,我们如何收集、使用、储存和分享这些信息,以及我们为您提供的访问、更新、控制和保护这些信息的方式。

本《隐私政策》与您所使用的 MmzMing的知识库 服务息息相关,希望您仔细阅读,在需要时,按照本《隐私政策》的指引,作出您认为适当的选择。本《隐私政策》中涉及的相关技术词汇,我们尽量以简明扼要的表述,并提供进一步说明的链接,以便您的理解。

您使用或继续使用我们的服务,即意味着同意我们按照本《隐私政策》收集、使用、储存和分享您的相关信息。

如对本《隐私政策》或相关事宜有任何问题,请通过 784774835@qq.com 与我们联系。

1. 我们收集的信息#

我们或我们的第三方合作伙伴提供服务时,可能会收集、储存和使用下列与您有关的信息。如果您不提供相关信息,可能无法注册成为我们的用户或无法享受我们提供的某些服务,或者无法达到相关服务拟达到的效果。

  • 个人信息:您在注册账户或使用我们的服务时,向我们提供的相关个人信息,例如电话号码、电子邮件等。
  • 日志信息:指您使用我们的服务时,系统可能通过 cookies、标识符及相关技术收集的信息,包括您的 设备信息浏览信息点击信息,并将该等信息储存为日志信息,为您提供个性化的用户体验、保障服务安全。您可以通过浏览器设置拒绝或管理 cookie、标识符或相关技术的使用。
  • 位置信息:指您开启设备定位功能并使用我们基于位置提供的相关服务时,收集的有关您位置的信息,包括:
    • 您通过具有定位功能的移动设备使用我们的服务时,通过 GPS 或 WiFi 等方式收集的您的地理位置信息;
    • 您可以通过关闭定位功能,停止对您的地理位置信息的收集。

2. 信息的存储#

2.1 信息存储的方式和期限#

我们会通过安全的方式存储您的信息,包括本地存储(例如利用 APP 进行数据缓存)、数据库和服务器日志。

一般情况下,我们只会在为实现服务目的所必需的时间内或法律法规规定的条件下存储您的个人信息。

2.2 信息存储的地域#

我们会按照法律法规规定,将境内收集的用户个人信息存储于中国境内。

目前我们不会跨境传输或存储您的个人信息。将来如需跨境传输或存储的,我们会向您告知信息出境的目的、接收方、安全保证措施和安全风险,并征得您的同意。

2.3 产品或服务停止运营时的通知#

当我们的产品或服务发生停止运营的情况时,我们将以推送通知、公告等形式通知您,并在合理期限内删除您的个人信息或进行匿名化处理,法律法规另有规定的除外。

3. 信息安全#

我们使用各种安全技术和程序,以防信息的丢失、不当使用、未经授权阅览或披露。例如,在某些服务中,我们将利用加密技术(例如 SSL)来保护您提供的个人信息。但请您理解,由于技术的限制以及可能存在的各种恶意手段,在互联网行业,即便竭尽所能加强安全措施,也不可能始终保证信息百分之百的安全。您需要了解,您接入我们的服务所用的系统和通讯网络,有可能因我们可控范围外的因素而出现问题。

4. 我们如何使用信息#

我们可能将在向您提供服务的过程之中所收集的信息用作下列用途:

  • 向您提供服务;
  • 在我们提供服务时,用于身份验证、客户服务、安全防范、诈骗监测、存档和备份用途,确保我们向您提供的产品和服务的安全性;
  • 帮助我们设计新服务,改善我们现有服务;
  • 使我们更加了解您如何接入和使用我们的服务,从而针对性地回应您的个性化需求,例如语言设定、位置设定、个性化的帮助服务和指示,或对您和其他用户作出其他方面的回应;
  • 向您提供与您更加相关的广告以替代普遍投放的广告;
  • 评估我们服务中的广告和其他促销及推广活动的效果,并加以改善;
  • 软件认证或管理软件升级;
  • 让您参与有关我们产品和服务的调查。

5. 信息共享#

目前,我们不会主动共享或转让您的个人信息至第三方,如存在其他共享或转让您的个人信息或您需要我们将您的个人信息共享或转让至第三方情形时,我们会直接或确认第三方征得您对上述行为的明示同意。

为了投放广告,评估、优化广告投放效果等目的,我们需要向广告主及其代理商等第三方合作伙伴共享您的部分数据,要求其严格遵守我们关于数据隐私保护的措施与要求,包括但不限于根据数据保护协议、承诺书及相关数据处理政策进行处理,避免识别出个人身份,保障隐私安全。

我们不会向合作伙伴分享可用于识别您个人身份的信息(例如您的姓名或电子邮件地址),除非您明确授权。

我们不会对外公开披露所收集的个人信息,如必须公开披露时,我们会向您告知此次公开披露的目的、披露信息的类型及可能涉及的敏感信息,并征得您的明示同意。

随着我们业务的持续发展,我们有可能进行合并、收购、资产转让等交易,我们将告知您相关情形,按照法律法规及不低于本《隐私政策》所要求的标准继续保护或要求新的控制者继续保护您的个人信息。

另外,根据相关法律法规及国家标准,以下情形中,我们可能会共享、转让、公开披露个人信息无需事先征得您的授权同意:

  • 与国家安全、国防安全直接相关的;
  • 与公共安全、公共卫生、重大公共利益直接相关的;
  • 犯罪侦查、起诉、审判和判决执行等直接相关的;
  • 出于维护个人信息主体或其他个人的生命、财产等重大合法权益但又很难得到本人同意的;
  • 个人信息主体自行向社会公众公开个人信息的;
  • 从合法公开披露的信息中收集个人信息的,如合法的新闻报道、政府信息公开等渠道。

6. 您的权利#

在您使用我们的服务期间,我们可能会视产品具体情况为您提供相应的操作设置,以便您可以查询、删除、更正或撤回您的相关个人信息,您可参考相应的具体指引进行操作。此外,我们还设置了投诉举报渠道,您的意见将会得到及时的处理。如果您无法通过上述途径和方式行使您的个人信息主体权利,您可以通过本《隐私政策》中提供的联系方式提出您的请求,我们会按照法律法规的规定予以反馈。

当您决定不再使用我们的产品或服务时,可以申请注销账户。注销账户后,除法律法规另有规定外,我们将删除或匿名化处理您的个人信息。

7. 变更#

我们可能适时修订本《隐私政策》的条款。当变更发生时,我们会在版本更新时向您提示新的《隐私政策》,并向您说明生效日期。请您仔细阅读变更后的《隐私政策》内容,若您继续使用我们的服务,即表示您同意我们按照更新后的《隐私政策》处理您的个人信息。

8. 未成年人保护#

我们鼓励父母或监护人指导未满十八岁的未成年人使用我们的服务。我们建议未成年人鼓励他们的父母或监护人阅读本《隐私政策》,并建议未成年人在提交的个人信息之前寻求父母或监护人的同意和指导。