feat(auth): 新增 App 扫码登录 PC 端

- 新增 auth/qrcode 模块:生成票据、轮询状态、换取会话令牌

- SecurityUser 增加 avatar 字段,UserSocial 支持按用户 ID 查认证信息

- 新增 QR_CODE_LOGIN Redis 票据与 A025x 扫码异常码

- security 白名单放行 /api/v1/auth/qr-code/*
This commit is contained in:
Ray.Hao
2026-07-18 01:04:04 +08:00
parent 666f4929e5
commit ae660f57e9
17 changed files with 537 additions and 0 deletions

View File

@@ -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;
/**
* 扫码登录接口。
* <p>
* 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<QrCodeGenerateVO> generate(HttpServletRequest request) {
return Result.success(qrCodeLoginService.generate(getClientIp(request)));
}
@Operation(summary = "查询扫码状态")
@GetMapping("/status")
@RateLimit(limit = 60, window = 60)
public Result<QrCodeStatusVO> status(@RequestParam String ticket) {
return Result.success(qrCodeLoginService.status(ticket));
}
@Operation(summary = "APP 标记已扫码")
@PostMapping("/scan")
public Result<QrCodeStatusVO> scan(@RequestBody @Valid QrCodeTicketForm form) {
return Result.success(qrCodeLoginService.scan(form.getTicket(), SecurityUtils.getUserId()));
}
@Operation(summary = "APP 确认登录")
@PostMapping("/confirm")
public Result<QrCodeStatusVO> confirm(@RequestBody @Valid QrCodeTicketForm form) {
return Result.success(qrCodeLoginService.confirm(form.getTicket(), SecurityUtils.getUserId()));
}
@Operation(summary = "APP 取消登录")
@PostMapping("/cancel")
public Result<QrCodeStatusVO> cancel(@RequestBody @Valid QrCodeTicketForm form) {
return Result.success(qrCodeLoginService.cancel(form.getTicket(), SecurityUtils.getUserId()));
}
@Operation(summary = "PC 端换取会话令牌")
@PostMapping("/login")
public Result<AuthenticationToken> 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();
}
}

View File

@@ -0,0 +1,39 @@
package com.youlai.boot.auth.qrcode.model;
import lombok.Data;
/**
* 扫码登录票据上下文,序列化为 JSON 存入 Redis。
* <p>
* 字段说明见 docs/youlai-boot/scan-code-login.md 的 Redis 存储设计。
*/
@Data
public class QrCodeLoginContext {
/** 票据UUID 无连字符 */
private String ticket;
/** 状态枚举名,取 {@link QrCodeLoginStatusEnum#name()} */
private String status;
/** 扫码用户 IDscan 时写入 */
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;
}

View File

@@ -0,0 +1,21 @@
package com.youlai.boot.auth.qrcode.model;
/**
* 扫码登录票据状态。
* <p>
* WAITING 票据已创建,等待 APP 扫码
* SCANNED APP 已扫码,等待用户在手机上确认
* CONFIRMED 用户已在 APP 上确认登录
* LOGGED_IN PC 已用票据换取会话令牌,票据作废,不可再用
* CANCELED 用户在 APP 上取消登录
* EXPIRED 票据超时,由 Redis TTL 自动清理,内存中通常不会出现该值
*/
public enum QrCodeLoginStatusEnum {
WAITING,
SCANNED,
CONFIRMED,
LOGGED_IN,
CANCELED,
EXPIRED
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 = "头像 URLSCANNED 之后返回")
private String avatar;
@Schema(description = "剩余有效期(秒)")
private Integer expireSeconds;
}

View File

@@ -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);
}

View File

@@ -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;
/**
* 扫码登录服务实现。
* <p>
* 票据上下文以 JSON 形式存入 RedisTTL 与有效期一致;状态流转的合法性全部由
* {@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<String, Object> 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);
}
}

View File

@@ -0,0 +1,34 @@
package com.youlai.boot.auth.qrcode.util;
/**
* 昵称脱敏:保留首尾各一个字符,中间用 * 替换。
* <p>
* 长度 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();
}
}

View File

@@ -43,6 +43,8 @@ public interface RedisConstants {
String REVOKED_JTI = BLACKLIST_TOKEN;
// 用户 Token 版本号(用于按用户失效历史 JWTtoken.tokenVersion != redis.tokenVersion => token 无效
String USER_TOKEN_VERSION = "auth:user:token_version:{}";
// 扫码登录票据上下文ticket -> QrCodeLoginContext
String QR_CODE_LOGIN = "auth:qr_code:{}";
}
/**

View File

@@ -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", "访问未授权"),

View File

@@ -47,6 +47,11 @@ public class SecurityUser {
*/
private Integer status;
/**
* 头像 URL
*/
private String avatar;
/**
* 角色编码集合
*/

View File

@@ -59,6 +59,14 @@ public interface UserSocialService extends IService<UserSocial> {
*/
SecurityUser getAuthInfoByOpenid(SocialPlatformEnum platform, String openid);
/**
* 根据用户ID获取用户认证信息
*
* @param userId 用户ID
* @return 用户认证信息
*/
SecurityUser getAuthInfoByUserId(Long userId);
/**
* 更新session_key
*

View File

@@ -94,6 +94,11 @@ public class UserSocialServiceImpl extends ServiceImpl<UserSocialMapper, UserSoc
return baseMapper.getAuthInfoByUserId(userSocial.getUserId());
}
@Override
public SecurityUser getAuthInfoByUserId(Long userId) {
return baseMapper.getAuthInfoByUserId(userId);
}
@Override
public void updateSessionKey(Long id, String sessionKey) {
UserSocial userSocial = getById(id);

View File

@@ -95,6 +95,9 @@ security:
- /api/v1/auth/refresh-token # 刷新令牌接口
- /api/v1/wxma/auth/** # 微信小程序认证接口(静默登录/手机号快捷登录/绑定手机号)
- /api/v1/logs/** # 日志接口(访问日志列表)
- /api/v1/auth/qr-code/generate # 扫码登录-生成票据
- /api/v1/auth/qr-code/status # 扫码登录-查询状态
- /api/v1/auth/qr-code/login # 扫码登录-换取会话令牌
# 非安全端点路径,完全绕过 Spring Security 的过滤器
unsecured-urls:
- ${springdoc.swagger-ui.path}

View File

@@ -94,6 +94,9 @@ security:
- /api/v1/auth/refresh-token # 刷新令牌接口
- /api/v1/wxma/auth/** # 微信小程序认证接口(静默登录/手机号快捷登录/绑定手机号)
- /api/v1/logs/** # 日志接口(访问日志列表)
- /api/v1/auth/qr-code/generate # 扫码登录-生成票据
- /api/v1/auth/qr-code/status # 扫码登录-查询状态
- /api/v1/auth/qr-code/login # 扫码登录-换取会话令牌
# 非安全端点路径,完全绕过 Spring Security 的过滤器
unsecured-urls:
- ${springdoc.swagger-ui.path}

View File

@@ -12,6 +12,7 @@
<result property="deptId" column="dept_id" jdbcType="BIGINT"/>
<result property="password" column="password" jdbcType="VARCHAR"/>
<result property="status" column="status" jdbcType="BOOLEAN"/>
<result property="avatar" column="avatar" jdbcType="VARCHAR"/>
<collection property="roles" ofType="string" javaType="java.util.Set">
<result column="code"/>
</collection>
@@ -26,6 +27,7 @@
u.dept_id,
u.password,
u.status,
u.avatar AS avatar,
r.code
FROM
sys_user u