feat(qr-login): 为微信小程序添加专用登录二维码

- 在 QrLoginGenerateResponse 中添加了 miniprogramPath 和 miniprogramQrCodeUrl 字段
- 在 QrLoginServiceImpl 中实现了微信小程序码的生成和返回
- 优化了原有二维码生成逻辑,增加了向后兼容的构造函数- 改进了时间处理方式,使用 DateUtil 替代 LocalDateTime- 重构了部分代码,提高了可维护性和扩展性
This commit is contained in:
2025-09-08 08:17:09 +08:00
parent ce5d43932e
commit db6eb892c4
2 changed files with 180 additions and 21 deletions

View File

@@ -20,10 +20,23 @@ public class QrLoginGenerateResponse {
@Schema(description = "扫码登录token")
private String token;
@Schema(description = "二维码内容")
private String qrCode;
@Schema(description = "二维码内容(APP扫码使用)")
private String qrCodeContent;
@Schema(description = "微信小程序页面路径")
private String miniprogramPath;
@Schema(description = "微信小程序码图片URL")
private String miniprogramQrCodeUrl;
@Schema(description = "过期时间(秒)")
private Long expiresIn;
// 保持向后兼容的构造函数
public QrLoginGenerateResponse(String token, String qrCodeContent, Long expiresIn) {
this.token = token;
this.qrCodeContent = qrCodeContent;
this.expiresIn = expiresIn;
}
}

View File

@@ -1,23 +1,28 @@
package com.gxwebsoft.auto.service.impl;
import cn.hutool.core.date.DateTime;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.lang.UUID;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.gxwebsoft.auto.dto.*;
import com.gxwebsoft.auto.service.QrLoginService;
import com.gxwebsoft.common.core.config.ConfigProperties;
import com.gxwebsoft.common.core.security.JwtSubject;
import com.gxwebsoft.common.core.security.JwtUtil;
import com.gxwebsoft.common.core.utils.RedisUtil;
import com.gxwebsoft.common.system.entity.User;
import com.gxwebsoft.common.system.service.SettingService;
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.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.io.File;
import java.util.HashMap;
import java.util.concurrent.TimeUnit;
import static com.gxwebsoft.common.core.constants.RedisConstants.*;
@@ -38,11 +43,16 @@ public class QrLoginServiceImpl implements QrLoginService {
@Autowired
private UserService userService;
@Value("${config.token-key}")
private String tokenKey;
@Autowired
private ConfigProperties configProperties;
@Value("${config.token-expire-time:86400}")
private Long tokenExpireTime;
@Autowired
private SettingService settingService;
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final String ACCESS_TOKEN_KEY = "WX_ACCESS_TOKEN";
@Override
public QrLoginGenerateResponse generateQrLoginToken() {
@@ -53,7 +63,7 @@ public class QrLoginServiceImpl implements QrLoginService {
QrLoginData qrLoginData = new QrLoginData();
qrLoginData.setToken(token);
qrLoginData.setStatus(QR_LOGIN_STATUS_PENDING);
// 五分钟过期
qrLoginData.setCreateTime(DateUtil.date());
qrLoginData.setExpireTime(DateUtil.offsetSecond(DateUtil.date(), QR_LOGIN_TOKEN_TTL.intValue()));
// 存储到Redis设置过期时间
@@ -62,10 +72,27 @@ public class QrLoginServiceImpl implements QrLoginService {
log.info("生成扫码登录token: {}", token);
// 构造二维码内容(这里可以是前端登录页面的URL + token参数)
String qrCodeContent = "qr-login:" + token;
// 构造响应对象
QrLoginGenerateResponse response = new QrLoginGenerateResponse();
response.setToken(token);
response.setExpiresIn(QR_LOGIN_TOKEN_TTL);
return new QrLoginGenerateResponse(token, qrCodeContent, QR_LOGIN_TOKEN_TTL);
// APP扫码内容
response.setQrCodeContent("qr-login:" + token);
// 微信小程序路径
response.setMiniprogramPath("/pages/qr-login?token=" + token);
// 生成微信小程序码
try {
String miniprogramQrCodeUrl = generateMiniprogramQrCode(token);
response.setMiniprogramQrCodeUrl(miniprogramQrCodeUrl);
} catch (Exception e) {
log.warn("生成微信小程序码失败: {}", e.getMessage());
// 小程序码生成失败不影响整体功能,继续返回其他信息
}
return response;
}
@Override
@@ -82,15 +109,14 @@ public class QrLoginServiceImpl implements QrLoginService {
}
// 检查是否过期
// 替换第85行代码为以下内容
if (LocalDateTime.now().isAfter(DateUtil.toLocalDateTime(qrLoginData.getExpireTime()))) {
if (DateUtil.date().after(qrLoginData.getExpireTime())) {
// 删除过期的token
redisUtil.delete(redisKey);
return new QrLoginStatusResponse(QR_LOGIN_STATUS_EXPIRED, null, null, 0L);
}
// 计算剩余过期时间
long expiresIn = ChronoUnit.SECONDS.between(LocalDateTime.now(), DateUtil.toLocalDateTime(qrLoginData.getExpireTime()));
long expiresIn = (qrLoginData.getExpireTime().getTime() - DateUtil.date().getTime()) / 1000;
QrLoginStatusResponse response = new QrLoginStatusResponse();
response.setStatus(qrLoginData.getStatus());
@@ -134,7 +160,7 @@ public class QrLoginServiceImpl implements QrLoginService {
}
// 检查是否过期
if (LocalDateTime.now().isAfter(DateUtil.toLocalDateTime(qrLoginData.getExpireTime()))) {
if (DateUtil.date().after(qrLoginData.getExpireTime())) {
redisUtil.delete(redisKey);
throw new RuntimeException("扫码登录token已过期");
}
@@ -152,7 +178,7 @@ public class QrLoginServiceImpl implements QrLoginService {
// 生成JWT token
JwtSubject jwtSubject = new JwtSubject(user.getUsername(), user.getTenantId());
String accessToken = JwtUtil.buildToken(jwtSubject, tokenExpireTime, tokenKey);
String accessToken = JwtUtil.buildToken(jwtSubject, configProperties.getTokenExpireTime(), configProperties.getTokenKey());
// 更新扫码登录数据
qrLoginData.setStatus(QR_LOGIN_STATUS_CONFIRMED);
@@ -185,7 +211,7 @@ public class QrLoginServiceImpl implements QrLoginService {
}
// 检查是否过期
if (LocalDateTime.now().isAfter(DateUtil.toLocalDateTime(qrLoginData.getExpireTime()))) {
if (DateUtil.date().after(qrLoginData.getExpireTime())) {
redisUtil.delete(redisKey);
return false;
}
@@ -195,7 +221,7 @@ public class QrLoginServiceImpl implements QrLoginService {
qrLoginData.setStatus(QR_LOGIN_STATUS_SCANNED);
// 计算剩余过期时间
long remainingSeconds = ChronoUnit.SECONDS.between(LocalDateTime.now(), DateUtil.toLocalDateTime(qrLoginData.getExpireTime()));
long remainingSeconds = (qrLoginData.getExpireTime().getTime() - DateUtil.date().getTime()) / 1000;
redisUtil.set(redisKey, qrLoginData, remainingSeconds, TimeUnit.SECONDS);
log.info("扫码登录token {} 状态更新为已扫码", token);
@@ -204,4 +230,124 @@ public class QrLoginServiceImpl implements QrLoginService {
return false;
}
/**
* 生成微信小程序码
*/
private String generateMiniprogramQrCode(String token) {
try {
String accessToken = getWxAccessToken();
if (StrUtil.isBlank(accessToken)) {
throw new RuntimeException("获取微信AccessToken失败");
}
String apiUrl = "https://api.weixin.qq.com/wxa/getwxacode?access_token=" + accessToken;
HashMap<String, Object> params = new HashMap<>();
params.put("path", "/pages/qr-login?token=" + token);
params.put("width", 430); // 二维码宽度默认430px
// 调用微信API生成小程序码
byte[] qrCodeBytes = HttpRequest.post(apiUrl)
.body(JSON.toJSONString(params))
.execute().bodyBytes();
// 保存文件
String fileName = "qr-login-" + token + ".png";
String uploadPath = getUploadPath();
String filePath = uploadPath + "qrcode/" + fileName;
// 确保目录存在
File dir = new File(uploadPath + "qrcode/");
if (!dir.exists()) {
dir.mkdirs();
}
File file = FileUtil.writeBytes(qrCodeBytes, filePath);
if (file != null && file.exists()) {
// 返回可访问的URL
return configProperties.getFileServer() + "/qrcode/" + fileName;
} else {
throw new RuntimeException("保存小程序码文件失败");
}
} catch (Exception e) {
log.error("生成微信小程序码失败: {}", e.getMessage(), e);
throw new RuntimeException("生成微信小程序码失败: " + e.getMessage());
}
}
/**
* 获取微信AccessToken
*/
private String getWxAccessToken() {
try {
String key = ACCESS_TOKEN_KEY + ":10048"; // 默认租户ID可以根据需要调整
// 从缓存获取
String cachedToken = redisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(cachedToken)) {
try {
JSONObject tokenData = JSON.parseObject(cachedToken);
String accessToken = tokenData.getString("access_token");
if (StrUtil.isNotBlank(accessToken)) {
return accessToken;
}
} catch (Exception e) {
// 解析失败,可能是旧格式,直接使用
if (!cachedToken.startsWith("{")) {
return cachedToken;
}
}
}
// 缓存中没有,重新获取
JSONObject setting = settingService.getBySettingKey("mp-weixin");
if (setting == null) {
throw new RuntimeException("请先配置微信小程序");
}
String appId = setting.getString("appId");
String appSecret = setting.getString("appSecret");
if (StrUtil.isBlank(appId) || StrUtil.isBlank(appSecret)) {
throw new RuntimeException("微信小程序配置不完整");
}
// 调用微信API获取AccessToken
String apiUrl = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid="
+ appId + "&secret=" + appSecret;
String response = HttpRequest.get(apiUrl).execute().body();
JSONObject result = JSON.parseObject(response);
String accessToken = result.getString("access_token");
if (StrUtil.isNotBlank(accessToken)) {
// 存入缓存
JSONObject tokenData = new JSONObject();
tokenData.put("access_token", accessToken);
tokenData.put("expires_in", result.get("expires_in"));
redisTemplate.opsForValue().set(key, tokenData.toJSONString(), 7000L, TimeUnit.SECONDS);
return accessToken;
} else {
throw new RuntimeException("获取AccessToken失败: " + response);
}
} catch (Exception e) {
log.error("获取微信AccessToken失败: {}", e.getMessage(), e);
return null;
}
}
/**
* 获取文件上传路径
*/
private String getUploadPath() {
String uploadPath = configProperties.getUploadPath();
if (StrUtil.isBlank(uploadPath)) {
uploadPath = configProperties.getLocalUploadPath();
}
if (StrUtil.isBlank(uploadPath)) {
uploadPath = "/tmp/uploads/";
}
if (!uploadPath.endsWith("/")) {
uploadPath += "/";
}
return uploadPath;
}
}