diff --git a/src/main/java/com/youlai/boot/auth/qrcode/controller/QrCodeLoginController.java b/src/main/java/com/youlai/boot/auth/qrcode/controller/QrCodeLoginController.java new file mode 100644 index 00000000..58570bca --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/qrcode/controller/QrCodeLoginController.java @@ -0,0 +1,89 @@ +package com.youlai.boot.auth.qrcode.controller; + +import cn.hutool.core.util.StrUtil; +import com.youlai.boot.auth.qrcode.model.form.QrCodeTicketForm; +import com.youlai.boot.auth.qrcode.model.vo.QrCodeGenerateVO; +import com.youlai.boot.auth.qrcode.model.vo.QrCodeStatusVO; +import com.youlai.boot.auth.qrcode.service.QrCodeLoginService; +import com.youlai.boot.common.annotation.RateLimit; +import com.youlai.boot.common.result.Result; +import com.youlai.boot.framework.security.model.AuthenticationToken; +import com.youlai.boot.framework.security.util.SecurityUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 扫码登录接口。 + *
+ * generate/status/login 不需要登录态(PC 端未登录),由 Security 配置放行;
+ * scan/confirm/cancel 需要 APP 端登录态,当前用户 ID 从 Security 上下文获取。
+ */
+@Tag(name = "02.扫码登录")
+@RestController
+@RequestMapping("/api/v1/auth/qr-code")
+@RequiredArgsConstructor
+public class QrCodeLoginController {
+
+ private final QrCodeLoginService qrCodeLoginService;
+
+ @Operation(summary = "生成扫码登录票据")
+ @PostMapping("/generate")
+ @RateLimit(limit = 30, window = 60)
+ public Result
+ * 字段说明见 docs/youlai-boot/scan-code-login.md 的 Redis 存储设计。
+ */
+@Data
+public class QrCodeLoginContext {
+
+ /** 票据,UUID 无连字符 */
+ private String ticket;
+
+ /** 状态枚举名,取 {@link QrCodeLoginStatusEnum#name()} */
+ private String status;
+
+ /** 扫码用户 ID,scan 时写入 */
+ private Long userId;
+
+ /** 用户原始昵称(未脱敏),status 接口返回时脱敏 */
+ private String nickname;
+
+ /** 用户头像 URL */
+ private String avatar;
+
+ /** 创建时间戳(毫秒) */
+ private Long createdAt;
+
+ /** 扫码时间戳(毫秒) */
+ private Long scannedAt;
+
+ /** 确认时间戳(毫秒) */
+ private Long confirmedAt;
+
+ /** generate 时的 PC 端 IP,用于审计 */
+ private String clientIp;
+}
diff --git a/src/main/java/com/youlai/boot/auth/qrcode/model/QrCodeLoginStatusEnum.java b/src/main/java/com/youlai/boot/auth/qrcode/model/QrCodeLoginStatusEnum.java
new file mode 100644
index 00000000..1f993c6e
--- /dev/null
+++ b/src/main/java/com/youlai/boot/auth/qrcode/model/QrCodeLoginStatusEnum.java
@@ -0,0 +1,21 @@
+package com.youlai.boot.auth.qrcode.model;
+
+/**
+ * 扫码登录票据状态。
+ *
+ * WAITING 票据已创建,等待 APP 扫码
+ * SCANNED APP 已扫码,等待用户在手机上确认
+ * CONFIRMED 用户已在 APP 上确认登录
+ * LOGGED_IN PC 已用票据换取会话令牌,票据作废,不可再用
+ * CANCELED 用户在 APP 上取消登录
+ * EXPIRED 票据超时,由 Redis TTL 自动清理,内存中通常不会出现该值
+ */
+public enum QrCodeLoginStatusEnum {
+
+ WAITING,
+ SCANNED,
+ CONFIRMED,
+ LOGGED_IN,
+ CANCELED,
+ EXPIRED
+}
diff --git a/src/main/java/com/youlai/boot/auth/qrcode/model/form/QrCodeTicketForm.java b/src/main/java/com/youlai/boot/auth/qrcode/model/form/QrCodeTicketForm.java
new file mode 100644
index 00000000..51d151b7
--- /dev/null
+++ b/src/main/java/com/youlai/boot/auth/qrcode/model/form/QrCodeTicketForm.java
@@ -0,0 +1,14 @@
+package com.youlai.boot.auth.qrcode.model.form;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+/**
+ * 扫码登录票据表单,用于 scan/confirm/cancel/login 接口。
+ */
+@Data
+public class QrCodeTicketForm {
+
+ @NotBlank(message = "票据不能为空")
+ private String ticket;
+}
diff --git a/src/main/java/com/youlai/boot/auth/qrcode/model/vo/QrCodeGenerateVO.java b/src/main/java/com/youlai/boot/auth/qrcode/model/vo/QrCodeGenerateVO.java
new file mode 100644
index 00000000..117ef015
--- /dev/null
+++ b/src/main/java/com/youlai/boot/auth/qrcode/model/vo/QrCodeGenerateVO.java
@@ -0,0 +1,20 @@
+package com.youlai.boot.auth.qrcode.model.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * generate 接口响应。
+ */
+@Data
+@Builder
+@Schema(description = "扫码票据生成结果")
+public class QrCodeGenerateVO {
+
+ @Schema(description = "票据")
+ private String ticket;
+
+ @Schema(description = "有效期(秒)")
+ private Integer expireSeconds;
+}
diff --git a/src/main/java/com/youlai/boot/auth/qrcode/model/vo/QrCodeStatusVO.java b/src/main/java/com/youlai/boot/auth/qrcode/model/vo/QrCodeStatusVO.java
new file mode 100644
index 00000000..9b3d649e
--- /dev/null
+++ b/src/main/java/com/youlai/boot/auth/qrcode/model/vo/QrCodeStatusVO.java
@@ -0,0 +1,29 @@
+package com.youlai.boot.auth.qrcode.model.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * status/scan/confirm/cancel 接口响应。
+ */
+@Data
+@Builder
+@Schema(description = "扫码登录状态")
+public class QrCodeStatusVO {
+
+ @Schema(description = "票据")
+ private String ticket;
+
+ @Schema(description = "状态:WAITING/SCANNED/CONFIRMED/LOGGED_IN/CANCELED/EXPIRED")
+ private String status;
+
+ @Schema(description = "脱敏昵称,SCANNED 之后返回")
+ private String nickname;
+
+ @Schema(description = "头像 URL,SCANNED 之后返回")
+ private String avatar;
+
+ @Schema(description = "剩余有效期(秒)")
+ private Integer expireSeconds;
+}
diff --git a/src/main/java/com/youlai/boot/auth/qrcode/service/QrCodeLoginService.java b/src/main/java/com/youlai/boot/auth/qrcode/service/QrCodeLoginService.java
new file mode 100644
index 00000000..9c8a2012
--- /dev/null
+++ b/src/main/java/com/youlai/boot/auth/qrcode/service/QrCodeLoginService.java
@@ -0,0 +1,29 @@
+package com.youlai.boot.auth.qrcode.service;
+
+import com.youlai.boot.auth.qrcode.model.vo.QrCodeGenerateVO;
+import com.youlai.boot.auth.qrcode.model.vo.QrCodeStatusVO;
+import com.youlai.boot.framework.security.model.AuthenticationToken;
+
+/**
+ * 扫码登录服务。
+ */
+public interface QrCodeLoginService {
+
+ /** 生成票据,未登录调用 */
+ QrCodeGenerateVO generate(String clientIp);
+
+ /** 查询状态,未登录调用 */
+ QrCodeStatusVO status(String ticket);
+
+ /** APP 标记已扫码,需 APP 端已登录 */
+ QrCodeStatusVO scan(String ticket, Long userId);
+
+ /** APP 确认登录,需 APP 端已登录 */
+ QrCodeStatusVO confirm(String ticket, Long userId);
+
+ /** APP 取消登录,需 APP 端已登录 */
+ QrCodeStatusVO cancel(String ticket, Long userId);
+
+ /** PC 端用票据换取会话令牌,未登录调用 */
+ AuthenticationToken login(String ticket);
+}
diff --git a/src/main/java/com/youlai/boot/auth/qrcode/service/impl/QrCodeLoginServiceImpl.java b/src/main/java/com/youlai/boot/auth/qrcode/service/impl/QrCodeLoginServiceImpl.java
new file mode 100644
index 00000000..9a488c60
--- /dev/null
+++ b/src/main/java/com/youlai/boot/auth/qrcode/service/impl/QrCodeLoginServiceImpl.java
@@ -0,0 +1,228 @@
+package com.youlai.boot.auth.qrcode.service.impl;
+
+import cn.hutool.core.util.IdUtil;
+import cn.hutool.core.util.StrUtil;
+import com.youlai.boot.auth.qrcode.model.QrCodeLoginContext;
+import com.youlai.boot.auth.qrcode.model.QrCodeLoginStatusEnum;
+import com.youlai.boot.auth.qrcode.model.vo.QrCodeGenerateVO;
+import com.youlai.boot.auth.qrcode.model.vo.QrCodeStatusVO;
+import com.youlai.boot.auth.qrcode.service.QrCodeLoginService;
+import com.youlai.boot.auth.qrcode.util.QrCodeNicknameMasker;
+import com.youlai.boot.common.constant.RedisConstants;
+import com.youlai.boot.common.result.ResultCode;
+import com.youlai.boot.framework.security.exception.TokenInvalidException;
+import com.youlai.boot.framework.security.model.AuthenticationToken;
+import com.youlai.boot.framework.security.model.SecurityUser;
+import com.youlai.boot.framework.security.model.SecurityUserDetails;
+import com.youlai.boot.framework.security.token.TokenManager;
+import com.youlai.boot.system.service.UserSocialService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.stereotype.Service;
+import tools.jackson.databind.json.JsonMapper;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 扫码登录服务实现。
+ *
+ * 票据上下文以 JSON 形式存入 Redis,TTL 与有效期一致;状态流转的合法性全部由
+ * {@link #requireStatus} / {@link #requireSameUser} 在写入前拦截,非法迁移直接抛业务异常,
+ * 因此同一 ticket 被多端并发操作时不需要分布式锁——最坏情况是后到的操作在状态校验处失败。
+ * PC 端换取会话时直接复用 {@link TokenManager#generateToken(Authentication)},
+ * 生成的令牌与账号密码登录走的是同一套会话治理(单/多设备、登出、刷新),前端无差别处理。
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class QrCodeLoginServiceImpl implements QrCodeLoginService {
+
+ /** 票据默认有效期(秒),二维码在此时间内未走完流程即由 Redis TTL 自动清理 */
+ private static final int DEFAULT_EXPIRE_SECONDS = 300;
+
+ /** 状态流转时若 Redis 剩余 TTL 小于该值,补足到此值,避免临界点票据在下一步操作前被清掉 */
+ private static final int MIN_REMAIN_SECONDS = 30;
+
+ /**
+ * 从 Redis 读回的值可能是不带类型信息的 Map(序列化器以 Object.class 存储),
+ * 用它把 Map 转回强类型上下文对象。JsonMapper 线程安全,复用单例。
+ */
+ private static final JsonMapper JSON_MAPPER = JsonMapper.builder().build();
+
+ private final RedisTemplate
+ * 长度 1:原样返回
+ * 长度 2:首字 + *
+ * 长度 ≥3:首字 + (n-2) 个 * + 末字
+ */
+public final class QrCodeNicknameMasker {
+
+ private QrCodeNicknameMasker() {
+ }
+
+ public static String mask(String nickname) {
+ if (nickname == null || nickname.isEmpty()) {
+ return "";
+ }
+ int len = nickname.length();
+ if (len == 1) {
+ return nickname;
+ }
+ if (len == 2) {
+ return nickname.charAt(0) + "*";
+ }
+ StringBuilder sb = new StringBuilder();
+ sb.append(nickname.charAt(0));
+ for (int i = 0; i < len - 2; i++) {
+ sb.append('*');
+ }
+ sb.append(nickname.charAt(len - 1));
+ return sb.toString();
+ }
+}
diff --git a/src/main/java/com/youlai/boot/common/constant/RedisConstants.java b/src/main/java/com/youlai/boot/common/constant/RedisConstants.java
index 4c43b8e5..64c9f805 100644
--- a/src/main/java/com/youlai/boot/common/constant/RedisConstants.java
+++ b/src/main/java/com/youlai/boot/common/constant/RedisConstants.java
@@ -43,6 +43,8 @@ public interface RedisConstants {
String REVOKED_JTI = BLACKLIST_TOKEN;
// 用户 Token 版本号(用于按用户失效历史 JWT):token.tokenVersion != redis.tokenVersion => token 无效
String USER_TOKEN_VERSION = "auth:user:token_version:{}";
+ // 扫码登录票据上下文(ticket -> QrCodeLoginContext)
+ String QR_CODE_LOGIN = "auth:qr_code:{}";
}
/**
diff --git a/src/main/java/com/youlai/boot/common/result/ResultCode.java b/src/main/java/com/youlai/boot/common/result/ResultCode.java
index 60b8668f..bdfb1c3b 100644
--- a/src/main/java/com/youlai/boot/common/result/ResultCode.java
+++ b/src/main/java/com/youlai/boot/common/result/ResultCode.java
@@ -80,6 +80,12 @@ public enum ResultCode implements IResultCode, Serializable {
USER_VERIFICATION_CODE_ATTEMPT_LIMIT_EXCEEDED("A0241", "用户验证码尝试次数超限"),
USER_VERIFICATION_CODE_EXPIRED("A0242", "用户验证码过期"),
+ /** A025x:扫码登录异常 */
+ QR_CODE_NOT_FOUND("A0250", "扫码登录票据不存在或已过期"),
+ QR_CODE_STATUS_ILLEGAL("A0251", "当前扫码状态不允许该操作"),
+ QR_CODE_USER_MISMATCH("A0252", "操作用户与扫码用户不一致"),
+ QR_CODE_ALREADY_USED("A0253", "扫码登录票据已被使用"),
+
/** A03xx:访问权限异常 */
ACCESS_PERMISSION_EXCEPTION("A0300", "访问权限异常"),
ACCESS_UNAUTHORIZED("A0301", "访问未授权"),
diff --git a/src/main/java/com/youlai/boot/framework/security/model/SecurityUser.java b/src/main/java/com/youlai/boot/framework/security/model/SecurityUser.java
index d9673e7f..96ee67ef 100644
--- a/src/main/java/com/youlai/boot/framework/security/model/SecurityUser.java
+++ b/src/main/java/com/youlai/boot/framework/security/model/SecurityUser.java
@@ -47,6 +47,11 @@ public class SecurityUser {
*/
private Integer status;
+ /**
+ * 头像 URL
+ */
+ private String avatar;
+
/**
* 角色编码集合
*/
diff --git a/src/main/java/com/youlai/boot/system/service/UserSocialService.java b/src/main/java/com/youlai/boot/system/service/UserSocialService.java
index 7f61ef1c..6d088626 100644
--- a/src/main/java/com/youlai/boot/system/service/UserSocialService.java
+++ b/src/main/java/com/youlai/boot/system/service/UserSocialService.java
@@ -59,6 +59,14 @@ public interface UserSocialService extends IService