feat(auth): 添加微信小程序扫码登录功能

- 新增扫码登录接口和相关服务
- 实现微信小程序端扫码登录逻辑
- 更新文档,添加微信小程序扫码登录指南
- 调整微信登录相关接口,使用 release 版本
- 新增 JWT 配置项
This commit is contained in:
2025-09-01 14:39:16 +08:00
parent fabccddab2
commit 9280f6284b
12 changed files with 784 additions and 2 deletions

View File

@@ -0,0 +1,104 @@
package com.gxwebsoft.auto.controller;
import com.gxwebsoft.auto.dto.QrLoginConfirmRequest;
import com.gxwebsoft.auto.dto.QrLoginGenerateResponse;
import com.gxwebsoft.auto.dto.QrLoginStatusResponse;
import com.gxwebsoft.auto.service.QrLoginService;
import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.common.core.web.ApiResult;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
/**
* 认证模块
*
* @author 科技小王子
* @since 2025-03-06 22:50:25
*/
@Tag(name = "认证模块")
@RestController
@RequestMapping("/api/qr-login")
public class QrLoginController extends BaseController {
@Autowired
private QrLoginService qrLoginService;
/**
* 生成扫码登录token
*/
@Operation(summary = "生成扫码登录token")
@PostMapping("/generate")
public ApiResult<?> generateQrLoginToken() {
try {
QrLoginGenerateResponse response = qrLoginService.generateQrLoginToken();
return success("生成成功", response);
} catch (Exception e) {
return fail(e.getMessage());
}
}
/**
* 检查扫码登录状态
*/
@Operation(summary = "检查扫码登录状态")
@GetMapping("/status/{token}")
public ApiResult<?> checkQrLoginStatus(
@Parameter(description = "扫码登录token") @PathVariable String token) {
try {
QrLoginStatusResponse response = qrLoginService.checkQrLoginStatus(token);
return success("查询成功", response);
} catch (Exception e) {
return fail(e.getMessage());
}
}
/**
* 确认扫码登录
*/
@Operation(summary = "确认扫码登录")
@PostMapping("/confirm")
public ApiResult<?> confirmQrLogin(@Valid @RequestBody QrLoginConfirmRequest request) {
try {
QrLoginStatusResponse response = qrLoginService.confirmQrLogin(request);
return success("确认成功", response);
} catch (Exception e) {
return fail(e.getMessage());
}
}
/**
* 扫码操作(可选接口,用于移动端扫码后更新状态)
*/
@Operation(summary = "扫码操作")
@PostMapping("/scan/{token}")
public ApiResult<?> scanQrCode(@Parameter(description = "扫码登录token") @PathVariable String token) {
try {
boolean result = qrLoginService.scanQrCode(token);
return success("操作成功", result);
} catch (Exception e) {
return fail(e.getMessage());
}
}
/**
* 微信小程序扫码登录确认(便捷接口)
*/
@Operation(summary = "微信小程序扫码登录确认")
@PostMapping("/wechat-confirm")
public ApiResult<?> wechatMiniProgramConfirm(@Valid @RequestBody QrLoginConfirmRequest request) {
try {
// 设置平台为微信小程序
request.setPlatform("miniprogram");
QrLoginStatusResponse response = qrLoginService.confirmQrLogin(request);
return success("微信小程序登录确认成功", response);
} catch (Exception e) {
return fail(e.getMessage());
}
}
}

View File

@@ -0,0 +1,50 @@
package com.gxwebsoft.auto.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 扫码登录确认请求
*
* @author 科技小王子
* @since 2025-08-31
*/
@Data
@Schema(description = "扫码登录确认请求")
public class QrLoginConfirmRequest {
@Schema(description = "扫码登录token")
@NotBlank(message = "token不能为空")
private String token;
@Schema(description = "用户ID")
private Integer userId;
@Schema(description = "登录平台: web-网页端, app-移动应用, miniprogram-微信小程序")
private String platform;
@Schema(description = "微信小程序相关信息")
private WechatMiniProgramInfo wechatInfo;
/**
* 微信小程序信息
*/
@Data
@Schema(description = "微信小程序信息")
public static class WechatMiniProgramInfo {
@Schema(description = "微信openid")
private String openid;
@Schema(description = "微信unionid")
private String unionid;
@Schema(description = "微信昵称")
private String nickname;
@Schema(description = "微信头像")
private String avatar;
}
}

View File

@@ -0,0 +1,55 @@
package com.gxwebsoft.auto.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 扫码登录数据模型
*
* @author 科技小王子
* @since 2025-08-31
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class QrLoginData {
/**
* 扫码登录token
*/
private String token;
/**
* 状态: pending-等待扫码, scanned-已扫码, confirmed-已确认, expired-已过期
*/
private String status;
/**
* 用户ID(扫码确认后设置)
*/
private Integer userId;
/**
* 用户名(扫码确认后设置)
*/
private String username;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 过期时间
*/
private LocalDateTime expireTime;
/**
* JWT访问令牌(确认后生成)
*/
private String accessToken;
}

View File

@@ -0,0 +1,29 @@
package com.gxwebsoft.auto.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 扫码登录生成响应
*
* @author 科技小王子
* @since 2025-08-31
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "扫码登录生成响应")
public class QrLoginGenerateResponse {
@Schema(description = "扫码登录token")
private String token;
@Schema(description = "二维码内容")
private String qrCode;
@Schema(description = "过期时间(秒)")
private Long expiresIn;
}

View File

@@ -0,0 +1,32 @@
package com.gxwebsoft.auto.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 扫码登录状态响应
*
* @author 科技小王子
* @since 2025-08-31
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "扫码登录状态响应")
public class QrLoginStatusResponse {
@Schema(description = "状态: pending-等待扫码, scanned-已扫码, confirmed-已确认, expired-已过期")
private String status;
@Schema(description = "JWT访问令牌(仅在confirmed状态时返回)")
private String accessToken;
@Schema(description = "用户信息(仅在confirmed状态时返回)")
private Object userInfo;
@Schema(description = "剩余过期时间(秒)")
private Long expiresIn;
}

View File

@@ -0,0 +1,46 @@
package com.gxwebsoft.auto.service;
import com.gxwebsoft.auto.dto.QrLoginConfirmRequest;
import com.gxwebsoft.auto.dto.QrLoginGenerateResponse;
import com.gxwebsoft.auto.dto.QrLoginStatusResponse;
/**
* 扫码登录服务接口
*
* @author 科技小王子
* @since 2025-08-31
*/
public interface QrLoginService {
/**
* 生成扫码登录token
*
* @return QrLoginGenerateResponse
*/
QrLoginGenerateResponse generateQrLoginToken();
/**
* 检查扫码登录状态
*
* @param token 扫码登录token
* @return QrLoginStatusResponse
*/
QrLoginStatusResponse checkQrLoginStatus(String token);
/**
* 确认扫码登录
*
* @param request 确认请求
* @return QrLoginStatusResponse
*/
QrLoginStatusResponse confirmQrLogin(QrLoginConfirmRequest request);
/**
* 扫码操作(更新状态为已扫码)
*
* @param token 扫码登录token
* @return boolean
*/
boolean scanQrCode(String token);
}

View File

@@ -0,0 +1,239 @@
package com.gxwebsoft.auto.service.impl;
import cn.hutool.core.lang.UUID;
import cn.hutool.core.util.StrUtil;
import com.gxwebsoft.auto.dto.*;
import com.gxwebsoft.auto.service.QrLoginService;
import com.gxwebsoft.common.core.security.JwtSubject;
import com.gxwebsoft.common.core.security.JwtUtil;
import com.gxwebsoft.common.core.utils.JSONUtil;
import com.gxwebsoft.common.core.utils.RedisUtil;
import com.gxwebsoft.common.system.entity.User;
import com.gxwebsoft.common.system.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.concurrent.TimeUnit;
import static com.gxwebsoft.common.core.constants.RedisConstants.*;
/**
* 扫码登录服务实现
*
* @author 科技小王子
* @since 2025-08-31
*/
@Slf4j
@Service
public class QrLoginServiceImpl implements QrLoginService {
@Autowired
private RedisUtil redisUtil;
@Autowired
private UserService userService;
@Value("${config.jwt.secret:websoft-jwt-secret-key-2025}")
private String jwtSecret;
@Value("${config.jwt.expire:86400}")
private Long jwtExpire;
@Override
public QrLoginGenerateResponse generateQrLoginToken() {
// 生成唯一的扫码登录token
String token = UUID.randomUUID().toString(true);
// 创建扫码登录数据
QrLoginData qrLoginData = new QrLoginData();
qrLoginData.setToken(token);
qrLoginData.setStatus(QR_LOGIN_STATUS_PENDING);
qrLoginData.setCreateTime(LocalDateTime.now());
qrLoginData.setExpireTime(LocalDateTime.now().plusSeconds(QR_LOGIN_TOKEN_TTL));
// 存储到Redis设置过期时间
String redisKey = QR_LOGIN_TOKEN_KEY + token;
redisUtil.set(redisKey, qrLoginData, QR_LOGIN_TOKEN_TTL, TimeUnit.SECONDS);
log.info("生成扫码登录token: {}", token);
// 构造二维码内容(这里可以是前端登录页面的URL + token参数)
String qrCodeContent = "qr-login:" + token;
return new QrLoginGenerateResponse(token, qrCodeContent, QR_LOGIN_TOKEN_TTL);
}
@Override
public QrLoginStatusResponse checkQrLoginStatus(String token) {
if (StrUtil.isBlank(token)) {
return new QrLoginStatusResponse(QR_LOGIN_STATUS_EXPIRED, null, null, 0L);
}
String redisKey = QR_LOGIN_TOKEN_KEY + token;
QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class);
if (qrLoginData == null) {
return new QrLoginStatusResponse(QR_LOGIN_STATUS_EXPIRED, null, null, 0L);
}
// 检查是否过期
if (LocalDateTime.now().isAfter(qrLoginData.getExpireTime())) {
// 删除过期的token
redisUtil.delete(redisKey);
return new QrLoginStatusResponse(QR_LOGIN_STATUS_EXPIRED, null, null, 0L);
}
// 计算剩余过期时间
long expiresIn = ChronoUnit.SECONDS.between(LocalDateTime.now(), qrLoginData.getExpireTime());
QrLoginStatusResponse response = new QrLoginStatusResponse();
response.setStatus(qrLoginData.getStatus());
response.setExpiresIn(expiresIn);
// 如果已确认返回token和用户信息
if (QR_LOGIN_STATUS_CONFIRMED.equals(qrLoginData.getStatus())) {
response.setAccessToken(qrLoginData.getAccessToken());
// 获取用户信息
if (qrLoginData.getUserId() != null) {
User user = userService.getByIdRel(qrLoginData.getUserId());
if (user != null) {
// 清除敏感信息
user.setPassword(null);
response.setUserInfo(user);
}
}
// 确认后删除token防止重复使用
redisUtil.delete(redisKey);
}
return response;
}
@Override
public QrLoginStatusResponse confirmQrLogin(QrLoginConfirmRequest request) {
String token = request.getToken();
Integer userId = request.getUserId();
String platform = request.getPlatform();
if (StrUtil.isBlank(token) || userId == null) {
throw new RuntimeException("参数不能为空");
}
String redisKey = QR_LOGIN_TOKEN_KEY + token;
QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class);
if (qrLoginData == null) {
throw new RuntimeException("扫码登录token不存在或已过期");
}
// 检查是否过期
if (LocalDateTime.now().isAfter(qrLoginData.getExpireTime())) {
redisUtil.delete(redisKey);
throw new RuntimeException("扫码登录token已过期");
}
// 获取用户信息
User user = userService.getByIdRel(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
}
// 检查用户状态
if (user.getStatus() != null && user.getStatus() != 0) {
throw new RuntimeException("用户已被冻结");
}
// 如果是微信小程序登录,处理微信相关信息
if ("miniprogram".equals(platform) && request.getWechatInfo() != null) {
handleWechatMiniProgramLogin(user, request.getWechatInfo());
}
// 生成JWT token
JwtSubject jwtSubject = new JwtSubject(user.getUsername(), user.getTenantId());
String accessToken = JwtUtil.buildToken(jwtSubject, jwtExpire, jwtSecret);
// 更新扫码登录数据
qrLoginData.setStatus(QR_LOGIN_STATUS_CONFIRMED);
qrLoginData.setUserId(userId);
qrLoginData.setUsername(user.getUsername());
qrLoginData.setAccessToken(accessToken);
// 更新Redis中的数据
redisUtil.set(redisKey, qrLoginData, 60L, TimeUnit.SECONDS); // 给前端60秒时间获取token
log.info("用户 {} 通过 {} 平台确认扫码登录token: {}", user.getUsername(),
platform != null ? platform : "unknown", token);
// 清除敏感信息
user.setPassword(null);
return new QrLoginStatusResponse(QR_LOGIN_STATUS_CONFIRMED, accessToken, user, 60L);
}
/**
* 处理微信小程序登录相关逻辑
*/
private void handleWechatMiniProgramLogin(User user, QrLoginConfirmRequest.WechatMiniProgramInfo wechatInfo) {
// 更新用户的微信信息
if (StrUtil.isNotBlank(wechatInfo.getOpenid())) {
user.setOpenid(wechatInfo.getOpenid());
}
if (StrUtil.isNotBlank(wechatInfo.getUnionid())) {
user.setUnionid(wechatInfo.getUnionid());
}
if (StrUtil.isNotBlank(wechatInfo.getNickname()) && StrUtil.isBlank(user.getNickname())) {
user.setNickname(wechatInfo.getNickname());
}
if (StrUtil.isNotBlank(wechatInfo.getAvatar()) && StrUtil.isBlank(user.getAvatar())) {
user.setAvatar(wechatInfo.getAvatar());
}
// 更新用户信息到数据库
try {
userService.updateById(user);
log.info("更新用户 {} 的微信小程序信息成功", user.getUsername());
} catch (Exception e) {
log.warn("更新用户 {} 的微信小程序信息失败: {}", user.getUsername(), e.getMessage());
}
}
@Override
public boolean scanQrCode(String token) {
if (StrUtil.isBlank(token)) {
return false;
}
String redisKey = QR_LOGIN_TOKEN_KEY + token;
QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class);
if (qrLoginData == null) {
return false;
}
// 检查是否过期
if (LocalDateTime.now().isAfter(qrLoginData.getExpireTime())) {
redisUtil.delete(redisKey);
return false;
}
// 只有pending状态才能更新为scanned
if (QR_LOGIN_STATUS_PENDING.equals(qrLoginData.getStatus())) {
qrLoginData.setStatus(QR_LOGIN_STATUS_SCANNED);
// 计算剩余过期时间
long remainingSeconds = ChronoUnit.SECONDS.between(LocalDateTime.now(), qrLoginData.getExpireTime());
redisUtil.set(redisKey, qrLoginData, remainingSeconds, TimeUnit.SECONDS);
log.info("扫码登录token {} 状态更新为已扫码", token);
return true;
}
return false;
}
}

View File

@@ -27,6 +27,14 @@ public class RedisConstants {
// 扫码登录相关key
public static final String QR_LOGIN_TOKEN_KEY = "qr-login:token:"; // 扫码登录token前缀
public static final Long QR_LOGIN_TOKEN_TTL = 300L; // 扫码登录token过期时间(5分钟)
public static final String QR_LOGIN_STATUS_PENDING = "pending"; // 等待扫码
public static final String QR_LOGIN_STATUS_SCANNED = "scanned"; // 已扫码
public static final String QR_LOGIN_STATUS_CONFIRMED = "confirmed"; // 已确认
public static final String QR_LOGIN_STATUS_EXPIRED = "expired"; // 已过期
// 哗啦啦key
public static final String getAllShop = "allShop";
public static final String getBaseInfo = "baseInfo";

View File

@@ -39,6 +39,7 @@ public class SecurityConfig {
.permitAll()
.antMatchers(
"/api/login",
"/api/qr-login/**",
"/api/register",
"/api/cms/website/createWebsite",
"/druid/**",

View File

@@ -402,7 +402,7 @@ public class WxLoginController extends BaseController {
String apiUrl = "https://api.weixin.qq.com/wxa/getwxacode?access_token=" + getAccessToken();
final HashMap<String, Object> map = new HashMap<>();
map.put("path", "/package/admin/order-scan?orderNo=".concat(orderNo));
map.put("env_version", "trial");
map.put("env_version", "release");
// 获取图片 Buffer
byte[] qrCode = HttpRequest.post(apiUrl)
.body(JSON.toJSONString(map))
@@ -439,7 +439,7 @@ public class WxLoginController extends BaseController {
final HashMap<String, Object> map = new HashMap<>();
map.put("scene", scene);
map.put("page", "pages/index/index");
map.put("env_version", "trial");
map.put("env_version", "release");
String jsonBody = JSON.toJSONString(map);
System.out.println("请求的 JSON body = " + jsonBody);

View File

@@ -48,6 +48,11 @@ config:
server-url: https://server.websoft.top/api
upload-path: /Users/gxwebsoft/JAVA/mp-java/src/main/resources/ # window(D:\Temp)
# JWT配置
jwt:
secret: websoft-jwt-secret-key-2025-dev-environment
expire: 86400 # token过期时间(秒) 24小时
# 开发环境证书配置
certificate:
load-mode: CLASSPATH # 开发环境从classpath加载