feat(qrLogin): 支持公众号扫码登录绑定手机号功能

- 新增扫码登录绑定手机号请求参数类 QrLoginBindPhoneRequest
- 在 QrLoginController 添加绑定手机号并完成扫码登录的接口
- QrLoginData 增加 needBindPhone 和 message 字段,支持绑定手机号状态描述
- QrLoginGenerateResponse 添加公众号二维码图片URL字段 wechatQrCodeUrl
- QrLoginService 新增 bindPhone 方法以支持手机号绑定流程
- QrLoginServiceImpl 实现手机号绑定逻辑,包含验证码校验及用户信息更新
- 优化扫码登录状态查询和确认逻辑,支持待绑定手机号状态及提示信息
- 生成公众号带参数二维码方法,实现公众号扫码登录二维码的生成
- 扫码状态新增 bind_phone 状态和对应常量,区分待绑定手机号阶段
- 改进扫码登录token过期判断与缓存处理,完善异常处理和日志记录
- 统一构建扫码登录状态响应,返回包含手机号绑定需求及状态信息
This commit is contained in:
2026-04-06 21:15:17 +08:00
parent 17c9aa0bcd
commit 655e6a6205
9 changed files with 269 additions and 120 deletions

View File

@@ -1,5 +1,6 @@
package com.gxwebsoft.auto.controller; package com.gxwebsoft.auto.controller;
import com.gxwebsoft.auto.dto.QrLoginBindPhoneRequest;
import com.gxwebsoft.auto.dto.QrLoginConfirmRequest; import com.gxwebsoft.auto.dto.QrLoginConfirmRequest;
import com.gxwebsoft.auto.dto.QrLoginGenerateResponse; import com.gxwebsoft.auto.dto.QrLoginGenerateResponse;
import com.gxwebsoft.auto.dto.QrLoginStatusResponse; import com.gxwebsoft.auto.dto.QrLoginStatusResponse;
@@ -93,6 +94,20 @@ public class QrLoginController extends BaseController {
} }
} }
/**
* 公众号关注注册后绑定手机号
*/
@Operation(summary = "绑定手机号并完成扫码登录")
@PostMapping("/bind-phone")
public ApiResult<?> bindPhone(@Valid @RequestBody QrLoginBindPhoneRequest request) {
try {
QrLoginStatusResponse response = qrLoginService.bindPhone(request);
return success("绑定成功", response);
} catch (Exception e) {
return fail(e.getMessage());
}
}
/** /**
* 微信扫码登录确认H5页面调用 * 微信扫码登录确认H5页面调用
*/ */
@@ -117,7 +132,7 @@ public class QrLoginController extends BaseController {
String appId = wxService.getOfficialAppId(getTenantId()); String appId = wxService.getOfficialAppId(getTenantId());
// 回调地址,指向 H5 扫码确认页面 // 回调地址,指向 H5 扫码确认页面
String redirectUri = java.net.URLEncoder.encode( String redirectUri = java.net.URLEncoder.encode(
"https://" + request.getHeader("Host") + "/wx-scan?token=" + token, "https://" + request.getHeader("Host") + "/wx-scan?token=" + token,
java.nio.charset.StandardCharsets.UTF_8); java.nio.charset.StandardCharsets.UTF_8);
// 构造微信 OAuth 授权 URL // 构造微信 OAuth 授权 URL
String oauthUrl = String.format( String oauthUrl = String.format(

View File

@@ -0,0 +1,30 @@
package com.gxwebsoft.auto.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 扫码登录绑定手机号请求
*
* @author 科技小王子
* @since 2026-04-06
*/
@Data
@Schema(description = "扫码登录绑定手机号请求")
public class QrLoginBindPhoneRequest {
@Schema(description = "扫码登录token")
@NotBlank(message = "token不能为空")
private String token;
@Schema(description = "手机号")
@NotBlank(message = "手机号不能为空")
private String phone;
@Schema(description = "短信验证码")
@NotBlank(message = "验证码不能为空")
private String code;
}

View File

@@ -4,8 +4,6 @@ import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.util.Date;
/** /**
* 扫码登录数据模型 * 扫码登录数据模型
* *
@@ -23,7 +21,7 @@ public class QrLoginData {
private String token; private String token;
/** /**
* 状态: pending-等待扫码, scanned-已扫码, confirmed-已确认, expired-已过期 * 状态: pending-等待扫码, scanned-已扫码, confirmed-已确认, bind_phone-待绑定手机号, expired-已过期
*/ */
private String status; private String status;
@@ -57,4 +55,14 @@ public class QrLoginData {
*/ */
private Integer tenantId; private Integer tenantId;
/**
* 是否需要绑定手机号
*/
private Boolean needBindPhone;
/**
* 状态提示信息
*/
private String message;
} }

View File

@@ -38,6 +38,9 @@ public class QrLoginGenerateResponse {
@Schema(description = "微信公众号AppID") @Schema(description = "微信公众号AppID")
private String wechatAppId; private String wechatAppId;
@Schema(description = "微信公众号带参数二维码图片URL")
private String wechatQrCodeUrl;
// 保持向后兼容的构造函数 // 保持向后兼容的构造函数
public QrLoginGenerateResponse(String token, String qrCodeContent, Long expiresIn) { public QrLoginGenerateResponse(String token, String qrCodeContent, Long expiresIn) {
this.token = token; this.token = token;

View File

@@ -2,7 +2,6 @@ package com.gxwebsoft.auto.dto;
import com.gxwebsoft.common.system.entity.User; import com.gxwebsoft.common.system.entity.User;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
@@ -14,17 +13,16 @@ import lombok.NoArgsConstructor;
*/ */
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor
@Schema(description = "扫码登录状态响应") @Schema(description = "扫码登录状态响应")
public class QrLoginStatusResponse { public class QrLoginStatusResponse {
@Schema(description = "状态: pending-等待扫码, scanned-已扫码, confirmed-已确认, expired-已过期") @Schema(description = "状态: pending-等待扫码, scanned-已扫码, confirmed-已确认, bind_phone-待绑定手机号, expired-已过期")
private String status; private String status;
@Schema(description = "JWT访问令牌(仅在confirmed状态时返回)") @Schema(description = "JWT访问令牌(仅在confirmed状态时返回)")
private String accessToken; private String accessToken;
@Schema(description = "用户信息(仅在confirmed状态时返回)") @Schema(description = "用户信息")
private User userInfo; private User userInfo;
@Schema(description = "剩余过期时间(秒)") @Schema(description = "剩余过期时间(秒)")
@@ -33,4 +31,18 @@ public class QrLoginStatusResponse {
@Schema(description = "租户ID") @Schema(description = "租户ID")
private Integer tenantId; private Integer tenantId;
@Schema(description = "是否需要绑定手机号")
private Boolean needBindPhone;
@Schema(description = "状态提示信息")
private String message;
public QrLoginStatusResponse(String status, String accessToken, User userInfo, Long expiresIn, Integer tenantId) {
this.status = status;
this.accessToken = accessToken;
this.userInfo = userInfo;
this.expiresIn = expiresIn;
this.tenantId = tenantId;
}
} }

View File

@@ -1,5 +1,6 @@
package com.gxwebsoft.auto.service; package com.gxwebsoft.auto.service;
import com.gxwebsoft.auto.dto.QrLoginBindPhoneRequest;
import com.gxwebsoft.auto.dto.QrLoginConfirmRequest; import com.gxwebsoft.auto.dto.QrLoginConfirmRequest;
import com.gxwebsoft.auto.dto.QrLoginGenerateResponse; import com.gxwebsoft.auto.dto.QrLoginGenerateResponse;
import com.gxwebsoft.auto.dto.QrLoginStatusResponse; import com.gxwebsoft.auto.dto.QrLoginStatusResponse;
@@ -45,6 +46,14 @@ public interface QrLoginService {
*/ */
boolean scanQrCode(String token); boolean scanQrCode(String token);
/**
* 关注后绑定手机号并完成登录
*
* @param request 绑定手机号请求
* @return QrLoginStatusResponse
*/
QrLoginStatusResponse bindPhone(QrLoginBindPhoneRequest request);
/** /**
* 微信扫码登录确认H5页面调用 * 微信扫码登录确认H5页面调用
* *

View File

@@ -3,6 +3,7 @@ package com.gxwebsoft.auto.service.impl;
import cn.hutool.core.date.DateUtil; import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil; import cn.hutool.core.io.FileUtil;
import cn.hutool.core.lang.UUID; import cn.hutool.core.lang.UUID;
import cn.hutool.core.util.DesensitizedUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest; import cn.hutool.http.HttpRequest;
import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSON;
@@ -13,6 +14,7 @@ import com.gxwebsoft.auto.service.QrLoginService;
import com.gxwebsoft.common.core.config.ConfigProperties; import com.gxwebsoft.common.core.config.ConfigProperties;
import com.gxwebsoft.common.core.security.JwtSubject; import com.gxwebsoft.common.core.security.JwtSubject;
import com.gxwebsoft.common.core.security.JwtUtil; import com.gxwebsoft.common.core.security.JwtUtil;
import com.gxwebsoft.common.core.utils.CommonUtil;
import com.gxwebsoft.common.core.utils.RedisUtil; import com.gxwebsoft.common.core.utils.RedisUtil;
import com.gxwebsoft.common.system.entity.User; import com.gxwebsoft.common.system.entity.User;
import com.gxwebsoft.common.system.entity.UserOauth; import com.gxwebsoft.common.system.entity.UserOauth;
@@ -27,8 +29,9 @@ import java.io.File;
import java.util.HashMap; import java.util.HashMap;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import static com.gxwebsoft.common.core.constants.RedisConstants.*;
import static com.gxwebsoft.common.core.constants.PlatformConstants.MP_OFFICIAL; import static com.gxwebsoft.common.core.constants.PlatformConstants.MP_OFFICIAL;
import static com.gxwebsoft.common.core.constants.RedisConstants.*;
import static com.gxwebsoft.common.core.constants.WebsiteConstants.CACHE_KEY_VERIFICATION_CODE_BY_DEV_SMS;
/** /**
* 扫码登录服务实现 * 扫码登录服务实现
@@ -55,66 +58,50 @@ public class QrLoginServiceImpl implements QrLoginService {
@Autowired(required = false) @Autowired(required = false)
private UserOauthService userOauthService; private UserOauthService userOauthService;
private static final String QR_LOGIN_TOKEN = "QR_LOGIN_TOKEN";
@Override @Override
public QrLoginGenerateResponse generateQrLoginToken(Integer tenantId) { public QrLoginGenerateResponse generateQrLoginToken(Integer tenantId) {
// 生成唯一的扫码登录token
String token = UUID.randomUUID().toString(true); String token = UUID.randomUUID().toString(true);
// 创建扫码登录数据
QrLoginData qrLoginData = new QrLoginData(); QrLoginData qrLoginData = new QrLoginData();
qrLoginData.setToken(token); qrLoginData.setToken(token);
qrLoginData.setStatus(QR_LOGIN_STATUS_PENDING); qrLoginData.setStatus(QR_LOGIN_STATUS_PENDING);
qrLoginData.setTenantId(tenantId); qrLoginData.setTenantId(tenantId);
qrLoginData.setNeedBindPhone(false);
qrLoginData.setMessage("等待微信扫码");
qrLoginData.setCreateTime(DateUtil.formatDateTime(DateUtil.date())); qrLoginData.setCreateTime(DateUtil.formatDateTime(DateUtil.date()));
qrLoginData.setExpireTime(DateUtil.formatDateTime(DateUtil.offsetSecond(DateUtil.date(), QR_LOGIN_TOKEN_TTL.intValue()))); qrLoginData.setExpireTime(DateUtil.formatDateTime(DateUtil.offsetSecond(DateUtil.date(), QR_LOGIN_TOKEN_TTL.intValue())));
// 存储到Redis设置过期时间
String redisKey = QR_LOGIN_TOKEN_KEY + token; String redisKey = QR_LOGIN_TOKEN_KEY + token;
redisUtil.set(redisKey, qrLoginData, QR_LOGIN_TOKEN_TTL, TimeUnit.SECONDS); redisUtil.set(redisKey, qrLoginData, QR_LOGIN_TOKEN_TTL, TimeUnit.SECONDS);
log.info("生成扫码登录token: {}", token); log.info("生成扫码登录token: {}", token);
// 构造响应对象
QrLoginGenerateResponse response = new QrLoginGenerateResponse(); QrLoginGenerateResponse response = new QrLoginGenerateResponse();
response.setToken(token); response.setToken(token);
response.setExpiresIn(QR_LOGIN_TOKEN_TTL); response.setExpiresIn(QR_LOGIN_TOKEN_TTL);
// APP扫码内容
response.setQrCodeContent("qr-login:" + token); response.setQrCodeContent("qr-login:" + token);
// 微信小程序路径
response.setMiniprogramPath("/pages/qr-login?token=" + token); response.setMiniprogramPath("/pages/qr-login?token=" + token);
// 生成微信小程序码
try { try {
String miniprogramQrCodeUrl = generateMiniprogramQrCode(token,tenantId); String miniprogramQrCodeUrl = generateMiniprogramQrCode(token, tenantId);
response.setMiniprogramQrCodeUrl(miniprogramQrCodeUrl); response.setMiniprogramQrCodeUrl(miniprogramQrCodeUrl);
} catch (Exception e) { } catch (Exception e) {
log.warn("生成微信小程序码失败: {}", e.getMessage()); log.warn("生成微信小程序码失败: {}", e.getMessage());
// 小程序码生成失败不影响整体功能,继续返回其他信息
} }
// 生成微信扫码登录 H5 页面 URL
try { try {
String appId = wxService.getOfficialAppId(tenantId); String appId = wxService.getOfficialAppId(tenantId);
// 优先使用专门的微信扫码配置,否则使用文件服务器地址
String baseUrl = configProperties.getWechatScanUrl(); String baseUrl = configProperties.getWechatScanUrl();
if (StrUtil.isBlank(baseUrl)) { if (StrUtil.isBlank(baseUrl)) {
baseUrl = configProperties.getFileServer(); baseUrl = "https://websopy.websoft.top";
} }
if (StrUtil.isBlank(baseUrl)) {
baseUrl = "https://server.websoft.top";
}
// 微信扫码后跳转的 H5 确认页面
String wechatScanUrl = baseUrl + "/wx-scan?token=" + token; String wechatScanUrl = baseUrl + "/wx-scan?token=" + token;
response.setWechatScanUrl(wechatScanUrl); response.setWechatScanUrl(wechatScanUrl);
response.setWechatAppId(appId); response.setWechatAppId(appId);
log.info("生成微信扫码登录URL: {}", wechatScanUrl); response.setWechatQrCodeUrl(generateOfficialQrCodeUrl(token, tenantId));
log.info("生成公众号扫码登录URL: {}", wechatScanUrl);
} catch (Exception e) { } catch (Exception e) {
log.warn("生成微信扫码URL失败: {}", e.getMessage()); log.warn("生成公众号扫码URL失败: {}", e.getMessage());
// 不影响整体功能
} }
return response; return response;
@@ -123,49 +110,34 @@ public class QrLoginServiceImpl implements QrLoginService {
@Override @Override
public QrLoginStatusResponse checkQrLoginStatus(String token) { public QrLoginStatusResponse checkQrLoginStatus(String token) {
if (StrUtil.isBlank(token)) { if (StrUtil.isBlank(token)) {
return new QrLoginStatusResponse(QR_LOGIN_STATUS_EXPIRED, null, null, 0L, null); return buildExpiredResponse();
} }
String redisKey = QR_LOGIN_TOKEN_KEY + token; String redisKey = QR_LOGIN_TOKEN_KEY + token;
QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class); QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class);
if (qrLoginData == null) { if (qrLoginData == null) {
return new QrLoginStatusResponse(QR_LOGIN_STATUS_EXPIRED, null, null, 0L, null); return buildExpiredResponse();
} }
// 检查是否过期 if (StrUtil.isBlank(qrLoginData.getExpireTime()) || DateUtil.date().after(DateUtil.parseDateTime(qrLoginData.getExpireTime()))) {
if (DateUtil.date().after(DateUtil.parseDateTime(qrLoginData.getExpireTime()))) {
// 删除过期的token
redisUtil.delete(redisKey); redisUtil.delete(redisKey);
return new QrLoginStatusResponse(QR_LOGIN_STATUS_EXPIRED, null, null, 0L, null); return buildExpiredResponse();
} }
// 计算剩余过期时间 long expiresIn = Math.max(0L,
long expiresIn = (DateUtil.parseDateTime(qrLoginData.getExpireTime()).getTime() - DateUtil.date().getTime()) / 1000; (DateUtil.parseDateTime(qrLoginData.getExpireTime()).getTime() - DateUtil.date().getTime()) / 1000);
QrLoginStatusResponse response = new QrLoginStatusResponse(); if (QR_LOGIN_STATUS_CONFIRMED.equals(qrLoginData.getStatus())
response.setStatus(qrLoginData.getStatus()); && StrUtil.isBlank(qrLoginData.getAccessToken())
response.setExpiresIn(expiresIn); && qrLoginData.getUserId() != null) {
response.setTenantId(qrLoginData.getTenantId()); User user = userService.getAllByUserId(String.valueOf(qrLoginData.getUserId()));
if (user != null) {
// 如果已确认返回token和用户信息 qrLoginData.setAccessToken(buildAccessToken(user));
if (QR_LOGIN_STATUS_CONFIRMED.equals(qrLoginData.getStatus())) { redisUtil.set(redisKey, qrLoginData, Math.max(expiresIn, 120L), TimeUnit.SECONDS);
response.setAccessToken(qrLoginData.getAccessToken());
// 获取用户信息
if (qrLoginData.getUserId() != null) {
User user = userService.getAllByUserId("" + qrLoginData.getUserId());
if (user != null) {
// 清除敏感信息
user.setPassword(null);
response.setUserInfo(user);
}
} }
// 确认后删除token防止重复使用
// redisUtil.delete(redisKey);
} }
return response; return buildStatusResponse(qrLoginData, expiresIn);
} }
@Override @Override
@@ -179,47 +151,35 @@ public class QrLoginServiceImpl implements QrLoginService {
String redisKey = QR_LOGIN_TOKEN_KEY + token; String redisKey = QR_LOGIN_TOKEN_KEY + token;
QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class); QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class);
if (qrLoginData == null) { if (qrLoginData == null) {
throw new RuntimeException("扫码登录token不存在或已过期"); throw new RuntimeException("扫码登录token不存在或已过期");
} }
// 检查是否过期 if (StrUtil.isBlank(qrLoginData.getExpireTime()) || DateUtil.date().after(DateUtil.parseDateTime(qrLoginData.getExpireTime()))) {
if (DateUtil.date().after(DateUtil.parseDateTime(qrLoginData.getExpireTime()))) {
redisUtil.delete(redisKey); redisUtil.delete(redisKey);
throw new RuntimeException("扫码登录token已过期"); throw new RuntimeException("扫码登录token已过期");
} }
// 获取用户信息
User user = userService.getAllByUserId(String.valueOf(userId)); User user = userService.getAllByUserId(String.valueOf(userId));
if (user == null) { if (user == null) {
throw new RuntimeException("用户不存在"); throw new RuntimeException("用户不存在");
} }
// 检查用户状态
if (user.getStatus() != null && user.getStatus() != 0) { if (user.getStatus() != null && user.getStatus() != 0) {
throw new RuntimeException("用户已被冻结"); throw new RuntimeException("用户已被冻结");
} }
// 生成JWT token String accessToken = buildAccessToken(user);
JwtSubject jwtSubject = new JwtSubject(user.getUsername(), user.getTenantId());
String accessToken = JwtUtil.buildToken(jwtSubject, configProperties.getTokenExpireTime(), configProperties.getTokenKey());
// 更新扫码登录数据
qrLoginData.setStatus(QR_LOGIN_STATUS_CONFIRMED); qrLoginData.setStatus(QR_LOGIN_STATUS_CONFIRMED);
qrLoginData.setUserId(userId); qrLoginData.setUserId(userId);
qrLoginData.setUsername(user.getUsername()); qrLoginData.setUsername(user.getUsername());
qrLoginData.setAccessToken(accessToken); qrLoginData.setAccessToken(accessToken);
qrLoginData.setTenantId(user.getTenantId()); qrLoginData.setTenantId(user.getTenantId());
// 更新Redis中的数据 qrLoginData.setNeedBindPhone(false);
redisUtil.set(redisKey, qrLoginData, 60L, TimeUnit.SECONDS); // 给前端60秒时间获取token qrLoginData.setMessage("登录成功");
redisUtil.set(redisKey, qrLoginData, 120L, TimeUnit.SECONDS);
log.info("用户 {} 确认扫码登录token: {}", user.getUsername(), token); log.info("用户 {} 确认扫码登录token: {}", user.getUsername(), token);
return buildStatusResponse(qrLoginData, 120L);
// 清除敏感信息
user.setPassword(null);
return new QrLoginStatusResponse(QR_LOGIN_STATUS_CONFIRMED, accessToken, user, 60L, user.getTenantId());
} }
@Override @Override
@@ -230,25 +190,21 @@ public class QrLoginServiceImpl implements QrLoginService {
String redisKey = QR_LOGIN_TOKEN_KEY + token; String redisKey = QR_LOGIN_TOKEN_KEY + token;
QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class); QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class);
if (qrLoginData == null) { if (qrLoginData == null) {
return false; return false;
} }
// 检查是否过期 if (StrUtil.isBlank(qrLoginData.getExpireTime()) || DateUtil.date().after(DateUtil.parseDateTime(qrLoginData.getExpireTime()))) {
if (DateUtil.date().after(DateUtil.parseDateTime(qrLoginData.getExpireTime()))) {
redisUtil.delete(redisKey); redisUtil.delete(redisKey);
return false; return false;
} }
// 只有pending状态才能更新为scanned
if (QR_LOGIN_STATUS_PENDING.equals(qrLoginData.getStatus())) { if (QR_LOGIN_STATUS_PENDING.equals(qrLoginData.getStatus())) {
qrLoginData.setStatus(QR_LOGIN_STATUS_SCANNED); qrLoginData.setStatus(QR_LOGIN_STATUS_SCANNED);
qrLoginData.setMessage("已识别扫码,等待公众号回调");
// 计算剩余过期时间 long remainingSeconds = Math.max(1L,
long remainingSeconds = (DateUtil.parseDateTime(qrLoginData.getExpireTime()).getTime() - DateUtil.date().getTime()) / 1000; (DateUtil.parseDateTime(qrLoginData.getExpireTime()).getTime() - DateUtil.date().getTime()) / 1000);
redisUtil.set(redisKey, qrLoginData, remainingSeconds, TimeUnit.SECONDS); redisUtil.set(redisKey, qrLoginData, remainingSeconds, TimeUnit.SECONDS);
log.info("扫码登录token {} 状态更新为已扫码", token); log.info("扫码登录token {} 状态更新为已扫码", token);
return true; return true;
} }
@@ -256,12 +212,82 @@ public class QrLoginServiceImpl implements QrLoginService {
return false; return false;
} }
@Override
public QrLoginStatusResponse bindPhone(QrLoginBindPhoneRequest request) {
if (request == null || StrUtil.isBlank(request.getToken()) || StrUtil.isBlank(request.getPhone()) || StrUtil.isBlank(request.getCode())) {
throw new RuntimeException("参数不能为空");
}
if (!CommonUtil.isValidPhoneNumber(request.getPhone())) {
throw new RuntimeException("请输入有效的手机号码");
}
String redisKey = QR_LOGIN_TOKEN_KEY + request.getToken();
QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class);
if (qrLoginData == null) {
throw new RuntimeException("二维码已过期,请刷新后重试");
}
if (StrUtil.isBlank(qrLoginData.getExpireTime()) || DateUtil.date().after(DateUtil.parseDateTime(qrLoginData.getExpireTime()))) {
redisUtil.delete(redisKey);
throw new RuntimeException("二维码已过期,请刷新后重试");
}
if (!QR_LOGIN_STATUS_BIND_PHONE.equals(qrLoginData.getStatus()) && !Boolean.TRUE.equals(qrLoginData.getNeedBindPhone())) {
throw new RuntimeException("当前二维码无需绑定手机号");
}
if (qrLoginData.getUserId() == null) {
throw new RuntimeException("绑定账号不存在,请重新扫码");
}
String codeKey = "code:" + request.getPhone();
String smsCode = redisUtil.get(codeKey);
String devCode = redisUtil.get(CACHE_KEY_VERIFICATION_CODE_BY_DEV_SMS);
if (StrUtil.isBlank(smsCode) && StrUtil.isBlank(devCode)) {
throw new RuntimeException("验证码已过期,请重新获取");
}
if (!StrUtil.equals(request.getCode(), smsCode) && !StrUtil.equals(request.getCode(), devCode)) {
throw new RuntimeException("验证码不正确");
}
User user = userService.getAllByUserId(String.valueOf(qrLoginData.getUserId()));
if (user == null) {
throw new RuntimeException("用户不存在");
}
if (user.getStatus() != null && user.getStatus() != 0) {
throw new RuntimeException("账号已被冻结");
}
User existed = userService.getByPhone(request.getPhone());
if (existed != null && !existed.getUserId().equals(user.getUserId())) {
throw new RuntimeException("该手机号已绑定其他账号");
}
user.setPhone(request.getPhone());
if (StrUtil.isBlank(user.getNickname()) || "微信公众号用户".equals(user.getNickname())) {
user.setNickname(DesensitizedUtil.mobilePhone(request.getPhone()));
}
if (StrUtil.isBlank(user.getUsername()) || user.getUsername().startsWith("wxoff_")) {
user.setUsername(request.getPhone());
}
userService.updateUser(user);
redisUtil.delete(codeKey);
String accessToken = buildAccessToken(user);
qrLoginData.setStatus(QR_LOGIN_STATUS_CONFIRMED);
qrLoginData.setUserId(user.getUserId());
qrLoginData.setUsername(user.getUsername());
qrLoginData.setTenantId(user.getTenantId());
qrLoginData.setAccessToken(accessToken);
qrLoginData.setNeedBindPhone(false);
qrLoginData.setMessage("手机号绑定成功,正在登录");
redisUtil.set(redisKey, qrLoginData, 120L, TimeUnit.SECONDS);
return buildStatusResponse(qrLoginData, 120L);
}
/** /**
* 生成微信小程序码 * 生成微信小程序码
*/ */
private String generateMiniprogramQrCode(String token, Integer tenantId) { private String generateMiniprogramQrCode(String token, Integer tenantId) {
try { try {
// 使用公共的 WxService 获取 AccessToken
String accessToken = wxService.getAccessToken(tenantId); String accessToken = wxService.getAccessToken(tenantId);
if (StrUtil.isBlank(accessToken)) { if (StrUtil.isBlank(accessToken)) {
throw new RuntimeException("获取微信AccessToken失败"); throw new RuntimeException("获取微信AccessToken失败");
@@ -270,19 +296,16 @@ public class QrLoginServiceImpl implements QrLoginService {
String apiUrl = "https://api.weixin.qq.com/wxa/getwxacode?access_token=" + accessToken; String apiUrl = "https://api.weixin.qq.com/wxa/getwxacode?access_token=" + accessToken;
HashMap<String, Object> params = new HashMap<>(); HashMap<String, Object> params = new HashMap<>();
params.put("path", "/pages/qr-login?token=" + token); params.put("path", "/pages/qr-login?token=" + token);
params.put("width", 430); // 二维码宽度默认430px params.put("width", 430);
// 调用微信API生成小程序码
byte[] qrCodeBytes = HttpRequest.post(apiUrl) byte[] qrCodeBytes = HttpRequest.post(apiUrl)
.body(JSON.toJSONString(params)) .body(JSON.toJSONString(params))
.execute().bodyBytes(); .execute().bodyBytes();
// 保存文件
String fileName = "qr-login-" + token + ".png"; String fileName = "qr-login-" + token + ".png";
String uploadPath = getUploadPath(); String uploadPath = getUploadPath();
String filePath = uploadPath + "qrcode/" + fileName; String filePath = uploadPath + "qrcode/" + fileName;
// 确保目录存在
File dir = new File(uploadPath + "qrcode/"); File dir = new File(uploadPath + "qrcode/");
if (!dir.exists()) { if (!dir.exists()) {
dir.mkdirs(); dir.mkdirs();
@@ -290,18 +313,48 @@ public class QrLoginServiceImpl implements QrLoginService {
File file = FileUtil.writeBytes(qrCodeBytes, filePath); File file = FileUtil.writeBytes(qrCodeBytes, filePath);
if (file != null && file.exists()) { if (file != null && file.exists()) {
// 返回可访问的URL
return configProperties.getFileServer() + "/qrcode/" + fileName; return configProperties.getFileServer() + "/qrcode/" + fileName;
} else {
throw new RuntimeException("保存小程序码文件失败");
} }
throw new RuntimeException("保存小程序码文件失败");
} catch (Exception e) { } catch (Exception e) {
log.error("生成微信小程序码失败: {}", e.getMessage(), e); log.error("生成微信小程序码失败: {}", e.getMessage(), e);
throw new RuntimeException("生成微信小程序码失败: " + e.getMessage()); throw new RuntimeException("生成微信小程序码失败: " + e.getMessage());
} }
} }
/**
* 生成公众号带参数二维码
*/
private String generateOfficialQrCodeUrl(String token, Integer tenantId) {
try {
String accessToken = wxService.getOfficialAccessToken(tenantId);
JSONObject scene = new JSONObject();
scene.put("scene_str", token);
JSONObject actionInfo = new JSONObject();
actionInfo.put("scene", scene);
JSONObject params = new JSONObject();
params.put("action_name", "QR_STR_SCENE");
params.put("expire_seconds", QR_LOGIN_TOKEN_TTL.intValue());
params.put("action_info", actionInfo);
String response = HttpRequest.post("https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=" + accessToken)
.body(params.toJSONString())
.timeout(10000)
.execute()
.body();
JSONObject result = JSON.parseObject(response);
String ticket = result.getString("ticket");
if (StrUtil.isBlank(ticket)) {
throw new RuntimeException("生成公众号二维码失败: " + response);
}
return "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket="
+ java.net.URLEncoder.encode(ticket, java.nio.charset.StandardCharsets.UTF_8);
} catch (Exception e) {
log.error("生成公众号二维码失败: {}", e.getMessage(), e);
throw new RuntimeException("生成公众号二维码失败: " + e.getMessage());
}
}
/** /**
* 获取文件上传路径 * 获取文件上传路径
@@ -320,6 +373,38 @@ public class QrLoginServiceImpl implements QrLoginService {
return uploadPath; return uploadPath;
} }
private String buildAccessToken(User user) {
JwtSubject jwtSubject = new JwtSubject(user.getUsername(), user.getTenantId());
return JwtUtil.buildToken(jwtSubject, configProperties.getTokenExpireTime(), configProperties.getTokenKey());
}
private QrLoginStatusResponse buildExpiredResponse() {
QrLoginStatusResponse response = new QrLoginStatusResponse(QR_LOGIN_STATUS_EXPIRED, null, null, 0L, null);
response.setNeedBindPhone(false);
response.setMessage("二维码已过期,请刷新后重试");
return response;
}
private QrLoginStatusResponse buildStatusResponse(QrLoginData qrLoginData, Long expiresIn) {
QrLoginStatusResponse response = new QrLoginStatusResponse();
response.setStatus(qrLoginData.getStatus());
response.setAccessToken(qrLoginData.getAccessToken());
response.setExpiresIn(expiresIn);
response.setTenantId(qrLoginData.getTenantId());
response.setNeedBindPhone(Boolean.TRUE.equals(qrLoginData.getNeedBindPhone())
|| QR_LOGIN_STATUS_BIND_PHONE.equals(qrLoginData.getStatus()));
response.setMessage(qrLoginData.getMessage());
if (qrLoginData.getUserId() != null) {
User user = userService.getAllByUserId(String.valueOf(qrLoginData.getUserId()));
if (user != null) {
user.setPassword(null);
response.setUserInfo(user);
}
}
return response;
}
@Override @Override
public WechatScanResponse wechatScanConfirm(WechatScanRequest request) { public WechatScanResponse wechatScanConfirm(WechatScanRequest request) {
String token = request.getToken(); String token = request.getToken();
@@ -329,13 +414,10 @@ public class QrLoginServiceImpl implements QrLoginService {
String redisKey = QR_LOGIN_TOKEN_KEY + token; String redisKey = QR_LOGIN_TOKEN_KEY + token;
QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class); QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class);
if (qrLoginData == null) { if (qrLoginData == null) {
return WechatScanResponse.notBound("二维码已过期,请刷新重试"); return WechatScanResponse.notBound("二维码已过期,请刷新重试");
} }
if (StrUtil.isBlank(qrLoginData.getExpireTime()) || DateUtil.date().after(DateUtil.parseDateTime(qrLoginData.getExpireTime()))) {
// 检查是否过期
if (DateUtil.date().after(DateUtil.parseDateTime(qrLoginData.getExpireTime()))) {
redisUtil.delete(redisKey); redisUtil.delete(redisKey);
return WechatScanResponse.notBound("二维码已过期,请刷新重试"); return WechatScanResponse.notBound("二维码已过期,请刷新重试");
} }
@@ -344,7 +426,6 @@ public class QrLoginServiceImpl implements QrLoginService {
String openId = request.getOpenId(); String openId = request.getOpenId();
Integer tenantId = qrLoginData.getTenantId(); Integer tenantId = qrLoginData.getTenantId();
// 如果没有直接传 unionId但有 code需要通过 code 获取
if (StrUtil.isBlank(unionId) && StrUtil.isNotBlank(request.getCode())) { if (StrUtil.isBlank(unionId) && StrUtil.isNotBlank(request.getCode())) {
try { try {
JSONObject userAccessToken = wxService.getOfficialUserAccessToken(request.getCode(), tenantId); JSONObject userAccessToken = wxService.getOfficialUserAccessToken(request.getCode(), tenantId);
@@ -362,8 +443,6 @@ public class QrLoginServiceImpl implements QrLoginService {
} }
User user = null; User user = null;
// 优先通过 unionId 查找用户
if (StrUtil.isNotBlank(unionId)) { if (StrUtil.isNotBlank(unionId)) {
user = userService.getOne(new LambdaQueryWrapper<User>() user = userService.getOne(new LambdaQueryWrapper<User>()
.eq(User::getUnionid, unionId) .eq(User::getUnionid, unionId)
@@ -372,9 +451,7 @@ public class QrLoginServiceImpl implements QrLoginService {
log.info("通过 unionId {} 查找用户: {}", unionId, user != null ? user.getUsername() : "未找到"); log.info("通过 unionId {} 查找用户: {}", unionId, user != null ? user.getUsername() : "未找到");
} }
// 如果通过 unionId 没找到,尝试通过 openId 查找
if (user == null && StrUtil.isNotBlank(openId)) { if (user == null && StrUtil.isNotBlank(openId)) {
// 尝试从 sys_user 表的 openid 字段查找
user = userService.getOne(new LambdaQueryWrapper<User>() user = userService.getOne(new LambdaQueryWrapper<User>()
.eq(User::getOpenid, openId) .eq(User::getOpenid, openId)
.eq(User::getDeleted, 0) .eq(User::getDeleted, 0)
@@ -382,7 +459,6 @@ public class QrLoginServiceImpl implements QrLoginService {
log.info("通过 openId {} 查找用户: {}", openId, user != null ? user.getUsername() : "未找到"); log.info("通过 openId {} 查找用户: {}", openId, user != null ? user.getUsername() : "未找到");
} }
// 如果还没找到,尝试从 sys_user_oauth 表查找
if (user == null && (StrUtil.isNotBlank(unionId) || StrUtil.isNotBlank(openId))) { if (user == null && (StrUtil.isNotBlank(unionId) || StrUtil.isNotBlank(openId))) {
try { try {
LambdaQueryWrapper<UserOauth> wrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<UserOauth> wrapper = new LambdaQueryWrapper<>();
@@ -410,30 +486,21 @@ public class QrLoginServiceImpl implements QrLoginService {
if (user == null) { if (user == null) {
return WechatScanResponse.notBound("该微信未绑定平台账号,请先在平台注册并绑定微信"); return WechatScanResponse.notBound("该微信未绑定平台账号,请先在平台注册并绑定微信");
} }
// 检查用户状态
if (user.getStatus() != null && user.getStatus() != 0) { if (user.getStatus() != null && user.getStatus() != 0) {
return WechatScanResponse.notBound("账号已被冻结"); return WechatScanResponse.notBound("账号已被冻结");
} }
// 生成 JWT token String accessToken = buildAccessToken(user);
JwtSubject jwtSubject = new JwtSubject(user.getUsername(), user.getTenantId());
String accessToken = JwtUtil.buildToken(jwtSubject, configProperties.getTokenExpireTime(), configProperties.getTokenKey());
// 更新扫码登录数据
qrLoginData.setStatus(QR_LOGIN_STATUS_CONFIRMED); qrLoginData.setStatus(QR_LOGIN_STATUS_CONFIRMED);
qrLoginData.setUserId(user.getUserId()); qrLoginData.setUserId(user.getUserId());
qrLoginData.setUsername(user.getUsername()); qrLoginData.setUsername(user.getUsername());
qrLoginData.setAccessToken(accessToken); qrLoginData.setAccessToken(accessToken);
qrLoginData.setTenantId(user.getTenantId()); qrLoginData.setTenantId(user.getTenantId());
// 更新Redis中的数据 qrLoginData.setNeedBindPhone(false);
redisUtil.set(redisKey, qrLoginData, 60L, TimeUnit.SECONDS); qrLoginData.setMessage("登录成功");
redisUtil.set(redisKey, qrLoginData, 120L, TimeUnit.SECONDS);
log.info("微信扫码登录成功,用户 {} 确认扫码登录token: {}", user.getUsername(), token);
// 清除敏感信息
user.setPassword(null); user.setPassword(null);
return WechatScanResponse.success(accessToken, user, user.getTenantId()); return WechatScanResponse.success(accessToken, user, user.getTenantId());
} }
} }

View File

@@ -33,6 +33,7 @@ public class RedisConstants {
public static final String QR_LOGIN_STATUS_PENDING = "pending"; // 等待扫码 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_SCANNED = "scanned"; // 已扫码
public static final String QR_LOGIN_STATUS_CONFIRMED = "confirmed"; // 已确认 public static final String QR_LOGIN_STATUS_CONFIRMED = "confirmed"; // 已确认
public static final String QR_LOGIN_STATUS_BIND_PHONE = "bind_phone"; // 待绑定手机号
public static final String QR_LOGIN_STATUS_EXPIRED = "expired"; // 已过期 public static final String QR_LOGIN_STATUS_EXPIRED = "expired"; // 已过期
// 哗啦啦key // 哗啦啦key

View File

@@ -11,6 +11,10 @@ import com.alibaba.fastjson.JSONObject;
import com.alipay.api.internal.util.file.IOUtils; import com.alipay.api.internal.util.file.IOUtils;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.qq.weixin.mp.aes.WXBizJsonMsgCrypt; import com.qq.weixin.mp.aes.WXBizJsonMsgCrypt;
import com.gxwebsoft.auto.dto.QrLoginData;
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.CommonUtil; import com.gxwebsoft.common.core.utils.CommonUtil;
import com.gxwebsoft.common.core.utils.JSONUtil; import com.gxwebsoft.common.core.utils.JSONUtil;
import com.gxwebsoft.common.core.utils.RedisUtil; import com.gxwebsoft.common.core.utils.RedisUtil;