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 generate(HttpServletRequest request) { + return Result.success(qrCodeLoginService.generate(getClientIp(request))); + } + + @Operation(summary = "查询扫码状态") + @GetMapping("/status") + @RateLimit(limit = 60, window = 60) + public Result status(@RequestParam String ticket) { + return Result.success(qrCodeLoginService.status(ticket)); + } + + @Operation(summary = "APP 标记已扫码") + @PostMapping("/scan") + public Result scan(@RequestBody @Valid QrCodeTicketForm form) { + return Result.success(qrCodeLoginService.scan(form.getTicket(), SecurityUtils.getUserId())); + } + + @Operation(summary = "APP 确认登录") + @PostMapping("/confirm") + public Result confirm(@RequestBody @Valid QrCodeTicketForm form) { + return Result.success(qrCodeLoginService.confirm(form.getTicket(), SecurityUtils.getUserId())); + } + + @Operation(summary = "APP 取消登录") + @PostMapping("/cancel") + public Result cancel(@RequestBody @Valid QrCodeTicketForm form) { + return Result.success(qrCodeLoginService.cancel(form.getTicket(), SecurityUtils.getUserId())); + } + + @Operation(summary = "PC 端换取会话令牌") + @PostMapping("/login") + public Result login(@RequestBody @Valid QrCodeTicketForm form) { + return Result.success(qrCodeLoginService.login(form.getTicket())); + } + + /** 从请求头或连接信息中提取客户端 IP,兼容反向代理 */ + private String getClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (StrUtil.isNotBlank(ip) && !"unknown".equalsIgnoreCase(ip)) { + int comma = ip.indexOf(','); + return comma > 0 ? ip.substring(0, comma).trim() : ip.trim(); + } + ip = request.getHeader("X-Real-IP"); + if (StrUtil.isNotBlank(ip) && !"unknown".equalsIgnoreCase(ip)) { + return ip.trim(); + } + return request.getRemoteAddr(); + } +} diff --git a/src/main/java/com/youlai/boot/auth/qrcode/model/QrCodeLoginContext.java b/src/main/java/com/youlai/boot/auth/qrcode/model/QrCodeLoginContext.java new file mode 100644 index 00000000..724c4f2e --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/qrcode/model/QrCodeLoginContext.java @@ -0,0 +1,39 @@ +package com.youlai.boot.auth.qrcode.model; + +import lombok.Data; + +/** + * 扫码登录票据上下文,序列化为 JSON 存入 Redis。 + *

+ * 字段说明见 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 redisTemplate; + private final TokenManager tokenManager; + private final UserSocialService userSocialService; + + @Override + public QrCodeGenerateVO generate(String clientIp) { + String ticket = IdUtil.fastSimpleUUID(); + QrCodeLoginContext ctx = new QrCodeLoginContext(); + ctx.setTicket(ticket); + ctx.setStatus(QrCodeLoginStatusEnum.WAITING.name()); + ctx.setCreatedAt(System.currentTimeMillis()); + ctx.setClientIp(clientIp); + save(ctx, DEFAULT_EXPIRE_SECONDS); + return QrCodeGenerateVO.builder() + .ticket(ticket) + .expireSeconds(DEFAULT_EXPIRE_SECONDS) + .build(); + } + + @Override + public QrCodeStatusVO status(String ticket) { + QrCodeLoginContext ctx = loadOrThrow(ticket); + return toVO(ctx, remainingSeconds(ticket)); + } + + @Override + public QrCodeStatusVO scan(String ticket, Long userId) { + QrCodeLoginContext ctx = loadOrThrow(ticket); + requireStatus(ctx, QrCodeLoginStatusEnum.WAITING); + fillUserInfo(ctx, userId); + ctx.setStatus(QrCodeLoginStatusEnum.SCANNED.name()); + ctx.setScannedAt(System.currentTimeMillis()); + save(ctx, refreshTtl(ticket)); + return toVO(ctx, remainingSeconds(ticket)); + } + + @Override + public QrCodeStatusVO confirm(String ticket, Long userId) { + QrCodeLoginContext ctx = loadOrThrow(ticket); + requireStatus(ctx, QrCodeLoginStatusEnum.SCANNED); + requireSameUser(ctx, userId); + ctx.setStatus(QrCodeLoginStatusEnum.CONFIRMED.name()); + ctx.setConfirmedAt(System.currentTimeMillis()); + save(ctx, refreshTtl(ticket)); + return toVO(ctx, remainingSeconds(ticket)); + } + + @Override + public QrCodeStatusVO cancel(String ticket, Long userId) { + QrCodeLoginContext ctx = loadOrThrow(ticket); + QrCodeLoginStatusEnum current = QrCodeLoginStatusEnum.valueOf(ctx.getStatus()); + // 只有还在流程中的票据(等待扫码 / 已扫码 / 已确认)允许取消,已登录或已取消的重复操作直接拒绝 + if (current != QrCodeLoginStatusEnum.WAITING + && current != QrCodeLoginStatusEnum.SCANNED + && current != QrCodeLoginStatusEnum.CONFIRMED) { + throw new TokenInvalidException(ResultCode.QR_CODE_STATUS_ILLEGAL); + } + // 一旦有人扫过码,取消权就归扫码本人,防止他人替扫码用户取消 + if (current != QrCodeLoginStatusEnum.WAITING && ctx.getUserId() != null) { + requireSameUser(ctx, userId); + } + ctx.setStatus(QrCodeLoginStatusEnum.CANCELED.name()); + save(ctx, refreshTtl(ticket)); + return toVO(ctx, remainingSeconds(ticket)); + } + + @Override + public AuthenticationToken login(String ticket) { + QrCodeLoginContext ctx = loadOrThrow(ticket); + requireStatus(ctx, QrCodeLoginStatusEnum.CONFIRMED); + SecurityUser securityUser = userSocialService.getAuthInfoByUserId(ctx.getUserId()); + if (securityUser == null) { + throw new TokenInvalidException(ResultCode.ACCOUNT_NOT_FOUND); + } + SecurityUserDetails details = new SecurityUserDetails(securityUser); + Authentication authentication = new UsernamePasswordAuthenticationToken( + details, null, details.getAuthorities()); + AuthenticationToken token = tokenManager.generateToken(authentication); + // 换取令牌成功后立即把票据置为已使用(一次性),再次 login 会在 requireStatus(CONFIRMED) 处被拒,杜绝重放 + ctx.setStatus(QrCodeLoginStatusEnum.LOGGED_IN.name()); + save(ctx, Math.max(remainingSeconds(ticket), MIN_REMAIN_SECONDS)); + return token; + } + + // ======================== private ======================== + + /** 读取票据上下文,票据为空、不存在或已过期都视为 QR_CODE_NOT_FOUND */ + private QrCodeLoginContext loadOrThrow(String ticket) { + if (StrUtil.isBlank(ticket)) { + throw new TokenInvalidException(ResultCode.QR_CODE_NOT_FOUND); + } + Object raw = redisTemplate.opsForValue().get(key(ticket)); + if (raw == null) { + throw new TokenInvalidException(ResultCode.QR_CODE_NOT_FOUND); + } + if (raw instanceof QrCodeLoginContext ctx) { + return ctx; + } + // 序列化器以 Object.class 存储、未带类型信息,读回来是 Map,这里再转回强类型 + try { + return JSON_MAPPER.convertValue(raw, QrCodeLoginContext.class); + } catch (Exception e) { + log.error("扫码登录上下文反序列化失败, ticket={}", ticket, e); + throw new TokenInvalidException(ResultCode.QR_CODE_NOT_FOUND); + } + } + + /** 当前状态必须等于期望状态,否则说明流程被跳步或重复操作 */ + private void requireStatus(QrCodeLoginContext ctx, QrCodeLoginStatusEnum expected) { + if (!expected.name().equals(ctx.getStatus())) { + throw new TokenInvalidException(ResultCode.QR_CODE_STATUS_ILLEGAL); + } + } + + /** 操作者必须是当初扫码的那个用户,防止 A 扫码 B 确认 */ + private void requireSameUser(QrCodeLoginContext ctx, Long userId) { + if (ctx.getUserId() == null || !ctx.getUserId().equals(userId)) { + throw new TokenInvalidException(ResultCode.QR_CODE_USER_MISMATCH); + } + } + + /** 扫码时把当前 APP 用户的昵称、头像写进上下文,供 PC 端 status 展示 */ + private void fillUserInfo(QrCodeLoginContext ctx, Long userId) { + SecurityUser info = userSocialService.getAuthInfoByUserId(userId); + if (info == null) { + throw new TokenInvalidException(ResultCode.ACCOUNT_NOT_FOUND); + } + ctx.setUserId(userId); + ctx.setNickname(info.getNickname()); + ctx.setAvatar(info.getAvatar()); + } + + /** 重写整个上下文并刷新 TTL */ + private void save(QrCodeLoginContext ctx, int ttl) { + redisTemplate.opsForValue().set(key(ctx.getTicket()), ctx, ttl, TimeUnit.SECONDS); + } + + /** 票据在 Redis 中的剩余秒数,取不到或已过期返回 0 */ + private int remainingSeconds(String ticket) { + Long ttl = redisTemplate.getExpire(key(ticket), TimeUnit.SECONDS); + return ttl == null ? 0 : Math.max(ttl.intValue(), 0); + } + + /** 状态流转时写回的 TTL:维持剩余时间,不足 MIN_REMAIN_SECONDS 则补足 */ + private int refreshTtl(String ticket) { + int remain = remainingSeconds(ticket); + return remain < MIN_REMAIN_SECONDS ? MIN_REMAIN_SECONDS : remain; + } + + /** 上下文转前端 VO,昵称脱敏、用户信息仅在扫码后暴露 */ + private QrCodeStatusVO toVO(QrCodeLoginContext ctx, int expireSeconds) { + QrCodeLoginStatusEnum status = QrCodeLoginStatusEnum.valueOf(ctx.getStatus()); + String nickname = null; + String avatar = null; + // WAITING 阶段谁都能查状态,此时不能泄露用户信息;扫码/确认后才回传脱敏昵称与头像 + if (status == QrCodeLoginStatusEnum.SCANNED + || status == QrCodeLoginStatusEnum.CONFIRMED) { + nickname = QrCodeNicknameMasker.mask(ctx.getNickname()); + avatar = ctx.getAvatar(); + } + return QrCodeStatusVO.builder() + .ticket(ctx.getTicket()) + .status(status.name()) + .nickname(nickname) + .avatar(avatar) + .expireSeconds(expireSeconds) + .build(); + } + + /** 拼接票据在 Redis 中的完整 Key */ + private String key(String ticket) { + return StrUtil.format(RedisConstants.Auth.QR_CODE_LOGIN, ticket); + } +} diff --git a/src/main/java/com/youlai/boot/auth/qrcode/util/QrCodeNicknameMasker.java b/src/main/java/com/youlai/boot/auth/qrcode/util/QrCodeNicknameMasker.java new file mode 100644 index 00000000..01fcc760 --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/qrcode/util/QrCodeNicknameMasker.java @@ -0,0 +1,34 @@ +package com.youlai.boot.auth.qrcode.util; + +/** + * 昵称脱敏:保留首尾各一个字符,中间用 * 替换。 + *

+ * 长度 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 { */ SecurityUser getAuthInfoByOpenid(SocialPlatformEnum platform, String openid); + /** + * 根据用户ID获取用户认证信息 + * + * @param userId 用户ID + * @return 用户认证信息 + */ + SecurityUser getAuthInfoByUserId(Long userId); + /** * 更新session_key * diff --git a/src/main/java/com/youlai/boot/system/service/impl/UserSocialServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/UserSocialServiceImpl.java index 96063870..d0891975 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/UserSocialServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/UserSocialServiceImpl.java @@ -94,6 +94,11 @@ public class UserSocialServiceImpl extends ServiceImpl + @@ -26,6 +27,7 @@ u.dept_id, u.password, u.status, + u.avatar AS avatar, r.code FROM sys_user u