From 655e6a6205b9ca51ff05f16715c98a98ba43ea0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com> Date: Mon, 6 Apr 2026 21:15:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(qrLogin):=20=E6=94=AF=E6=8C=81=E5=85=AC?= =?UTF-8?q?=E4=BC=97=E5=8F=B7=E6=89=AB=E7=A0=81=E7=99=BB=E5=BD=95=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=E6=89=8B=E6=9C=BA=E5=8F=B7=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增扫码登录绑定手机号请求参数类 QrLoginBindPhoneRequest - 在 QrLoginController 添加绑定手机号并完成扫码登录的接口 - QrLoginData 增加 needBindPhone 和 message 字段,支持绑定手机号状态描述 - QrLoginGenerateResponse 添加公众号二维码图片URL字段 wechatQrCodeUrl - QrLoginService 新增 bindPhone 方法以支持手机号绑定流程 - QrLoginServiceImpl 实现手机号绑定逻辑,包含验证码校验及用户信息更新 - 优化扫码登录状态查询和确认逻辑,支持待绑定手机号状态及提示信息 - 生成公众号带参数二维码方法,实现公众号扫码登录二维码的生成 - 扫码状态新增 bind_phone 状态和对应常量,区分待绑定手机号阶段 - 改进扫码登录token过期判断与缓存处理,完善异常处理和日志记录 - 统一构建扫码登录状态响应,返回包含手机号绑定需求及状态信息 --- .../auto/controller/QrLoginController.java | 17 +- .../auto/dto/QrLoginBindPhoneRequest.java | 30 ++ .../com/gxwebsoft/auto/dto/QrLoginData.java | 14 +- .../auto/dto/QrLoginGenerateResponse.java | 3 + .../auto/dto/QrLoginStatusResponse.java | 20 +- .../auto/service/QrLoginService.java | 9 + .../auto/service/impl/QrLoginServiceImpl.java | 291 +++++++++++------- .../common/core/constants/RedisConstants.java | 1 + .../controller/WxOfficialController.java | 4 + 9 files changed, 269 insertions(+), 120 deletions(-) create mode 100644 src/main/java/com/gxwebsoft/auto/dto/QrLoginBindPhoneRequest.java diff --git a/src/main/java/com/gxwebsoft/auto/controller/QrLoginController.java b/src/main/java/com/gxwebsoft/auto/controller/QrLoginController.java index 360172f..9535c80 100644 --- a/src/main/java/com/gxwebsoft/auto/controller/QrLoginController.java +++ b/src/main/java/com/gxwebsoft/auto/controller/QrLoginController.java @@ -1,5 +1,6 @@ package com.gxwebsoft.auto.controller; +import com.gxwebsoft.auto.dto.QrLoginBindPhoneRequest; import com.gxwebsoft.auto.dto.QrLoginConfirmRequest; import com.gxwebsoft.auto.dto.QrLoginGenerateResponse; 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页面调用) */ @@ -117,7 +132,7 @@ public class QrLoginController extends BaseController { String appId = wxService.getOfficialAppId(getTenantId()); // 回调地址,指向 H5 扫码确认页面 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); // 构造微信 OAuth 授权 URL String oauthUrl = String.format( diff --git a/src/main/java/com/gxwebsoft/auto/dto/QrLoginBindPhoneRequest.java b/src/main/java/com/gxwebsoft/auto/dto/QrLoginBindPhoneRequest.java new file mode 100644 index 0000000..ccdfb40 --- /dev/null +++ b/src/main/java/com/gxwebsoft/auto/dto/QrLoginBindPhoneRequest.java @@ -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; + +} diff --git a/src/main/java/com/gxwebsoft/auto/dto/QrLoginData.java b/src/main/java/com/gxwebsoft/auto/dto/QrLoginData.java index 39fadcc..69b131b 100644 --- a/src/main/java/com/gxwebsoft/auto/dto/QrLoginData.java +++ b/src/main/java/com/gxwebsoft/auto/dto/QrLoginData.java @@ -4,8 +4,6 @@ import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; -import java.util.Date; - /** * 扫码登录数据模型 * @@ -23,7 +21,7 @@ public class QrLoginData { private String token; /** - * 状态: pending-等待扫码, scanned-已扫码, confirmed-已确认, expired-已过期 + * 状态: pending-等待扫码, scanned-已扫码, confirmed-已确认, bind_phone-待绑定手机号, expired-已过期 */ private String status; @@ -57,4 +55,14 @@ public class QrLoginData { */ private Integer tenantId; + /** + * 是否需要绑定手机号 + */ + private Boolean needBindPhone; + + /** + * 状态提示信息 + */ + private String message; + } diff --git a/src/main/java/com/gxwebsoft/auto/dto/QrLoginGenerateResponse.java b/src/main/java/com/gxwebsoft/auto/dto/QrLoginGenerateResponse.java index 2e60d46..d0e1469 100644 --- a/src/main/java/com/gxwebsoft/auto/dto/QrLoginGenerateResponse.java +++ b/src/main/java/com/gxwebsoft/auto/dto/QrLoginGenerateResponse.java @@ -38,6 +38,9 @@ public class QrLoginGenerateResponse { @Schema(description = "微信公众号AppID") private String wechatAppId; + @Schema(description = "微信公众号带参数二维码图片URL") + private String wechatQrCodeUrl; + // 保持向后兼容的构造函数 public QrLoginGenerateResponse(String token, String qrCodeContent, Long expiresIn) { this.token = token; diff --git a/src/main/java/com/gxwebsoft/auto/dto/QrLoginStatusResponse.java b/src/main/java/com/gxwebsoft/auto/dto/QrLoginStatusResponse.java index fc37407..99114aa 100644 --- a/src/main/java/com/gxwebsoft/auto/dto/QrLoginStatusResponse.java +++ b/src/main/java/com/gxwebsoft/auto/dto/QrLoginStatusResponse.java @@ -2,7 +2,6 @@ package com.gxwebsoft.auto.dto; import com.gxwebsoft.common.system.entity.User; import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -14,17 +13,16 @@ import lombok.NoArgsConstructor; */ @Data @NoArgsConstructor -@AllArgsConstructor @Schema(description = "扫码登录状态响应") public class QrLoginStatusResponse { - @Schema(description = "状态: pending-等待扫码, scanned-已扫码, confirmed-已确认, expired-已过期") + @Schema(description = "状态: pending-等待扫码, scanned-已扫码, confirmed-已确认, bind_phone-待绑定手机号, expired-已过期") private String status; @Schema(description = "JWT访问令牌(仅在confirmed状态时返回)") private String accessToken; - @Schema(description = "用户信息(仅在confirmed状态时返回)") + @Schema(description = "用户信息") private User userInfo; @Schema(description = "剩余过期时间(秒)") @@ -33,4 +31,18 @@ public class QrLoginStatusResponse { @Schema(description = "租户ID") 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; + } + } diff --git a/src/main/java/com/gxwebsoft/auto/service/QrLoginService.java b/src/main/java/com/gxwebsoft/auto/service/QrLoginService.java index 659aba0..538bed5 100644 --- a/src/main/java/com/gxwebsoft/auto/service/QrLoginService.java +++ b/src/main/java/com/gxwebsoft/auto/service/QrLoginService.java @@ -1,5 +1,6 @@ package com.gxwebsoft.auto.service; +import com.gxwebsoft.auto.dto.QrLoginBindPhoneRequest; import com.gxwebsoft.auto.dto.QrLoginConfirmRequest; import com.gxwebsoft.auto.dto.QrLoginGenerateResponse; import com.gxwebsoft.auto.dto.QrLoginStatusResponse; @@ -45,6 +46,14 @@ public interface QrLoginService { */ boolean scanQrCode(String token); + /** + * 关注后绑定手机号并完成登录 + * + * @param request 绑定手机号请求 + * @return QrLoginStatusResponse + */ + QrLoginStatusResponse bindPhone(QrLoginBindPhoneRequest request); + /** * 微信扫码登录确认(H5页面调用) * diff --git a/src/main/java/com/gxwebsoft/auto/service/impl/QrLoginServiceImpl.java b/src/main/java/com/gxwebsoft/auto/service/impl/QrLoginServiceImpl.java index 98755a9..ac1d1df 100644 --- a/src/main/java/com/gxwebsoft/auto/service/impl/QrLoginServiceImpl.java +++ b/src/main/java/com/gxwebsoft/auto/service/impl/QrLoginServiceImpl.java @@ -3,6 +3,7 @@ package com.gxwebsoft.auto.service.impl; import cn.hutool.core.date.DateUtil; import cn.hutool.core.io.FileUtil; import cn.hutool.core.lang.UUID; +import cn.hutool.core.util.DesensitizedUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.http.HttpRequest; 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.security.JwtSubject; 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.system.entity.User; import com.gxwebsoft.common.system.entity.UserOauth; @@ -27,8 +29,9 @@ import java.io.File; import java.util.HashMap; 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.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) private UserOauthService userOauthService; - private static final String QR_LOGIN_TOKEN = "QR_LOGIN_TOKEN"; - @Override public QrLoginGenerateResponse generateQrLoginToken(Integer tenantId) { - // 生成唯一的扫码登录token String token = UUID.randomUUID().toString(true); - // 创建扫码登录数据 QrLoginData qrLoginData = new QrLoginData(); qrLoginData.setToken(token); qrLoginData.setStatus(QR_LOGIN_STATUS_PENDING); qrLoginData.setTenantId(tenantId); + qrLoginData.setNeedBindPhone(false); + qrLoginData.setMessage("等待微信扫码"); qrLoginData.setCreateTime(DateUtil.formatDateTime(DateUtil.date())); qrLoginData.setExpireTime(DateUtil.formatDateTime(DateUtil.offsetSecond(DateUtil.date(), QR_LOGIN_TOKEN_TTL.intValue()))); - // 存储到Redis,设置过期时间 String redisKey = QR_LOGIN_TOKEN_KEY + token; redisUtil.set(redisKey, qrLoginData, QR_LOGIN_TOKEN_TTL, TimeUnit.SECONDS); log.info("生成扫码登录token: {}", token); - // 构造响应对象 QrLoginGenerateResponse response = new QrLoginGenerateResponse(); response.setToken(token); response.setExpiresIn(QR_LOGIN_TOKEN_TTL); - - // APP扫码内容 response.setQrCodeContent("qr-login:" + token); - - // 微信小程序路径 response.setMiniprogramPath("/pages/qr-login?token=" + token); - // 生成微信小程序码 try { - String miniprogramQrCodeUrl = generateMiniprogramQrCode(token,tenantId); + String miniprogramQrCodeUrl = generateMiniprogramQrCode(token, tenantId); response.setMiniprogramQrCodeUrl(miniprogramQrCodeUrl); } catch (Exception e) { log.warn("生成微信小程序码失败: {}", e.getMessage()); - // 小程序码生成失败不影响整体功能,继续返回其他信息 } - // 生成微信扫码登录 H5 页面 URL try { String appId = wxService.getOfficialAppId(tenantId); - // 优先使用专门的微信扫码配置,否则使用文件服务器地址 String baseUrl = configProperties.getWechatScanUrl(); 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; response.setWechatScanUrl(wechatScanUrl); response.setWechatAppId(appId); - log.info("生成微信扫码登录URL: {}", wechatScanUrl); + response.setWechatQrCodeUrl(generateOfficialQrCodeUrl(token, tenantId)); + log.info("生成公众号扫码登录URL: {}", wechatScanUrl); } catch (Exception e) { - log.warn("生成微信扫码URL失败: {}", e.getMessage()); - // 不影响整体功能 + log.warn("生成公众号扫码URL失败: {}", e.getMessage()); } return response; @@ -123,49 +110,34 @@ public class QrLoginServiceImpl implements QrLoginService { @Override public QrLoginStatusResponse checkQrLoginStatus(String 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; QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class); - if (qrLoginData == null) { - return new QrLoginStatusResponse(QR_LOGIN_STATUS_EXPIRED, null, null, 0L, null); + return buildExpiredResponse(); } - // 检查是否过期 - if (DateUtil.date().after(DateUtil.parseDateTime(qrLoginData.getExpireTime()))) { - // 删除过期的token + if (StrUtil.isBlank(qrLoginData.getExpireTime()) || DateUtil.date().after(DateUtil.parseDateTime(qrLoginData.getExpireTime()))) { redisUtil.delete(redisKey); - return new QrLoginStatusResponse(QR_LOGIN_STATUS_EXPIRED, null, null, 0L, null); + return buildExpiredResponse(); } - // 计算剩余过期时间 - long expiresIn = (DateUtil.parseDateTime(qrLoginData.getExpireTime()).getTime() - DateUtil.date().getTime()) / 1000; + long expiresIn = Math.max(0L, + (DateUtil.parseDateTime(qrLoginData.getExpireTime()).getTime() - DateUtil.date().getTime()) / 1000); - QrLoginStatusResponse response = new QrLoginStatusResponse(); - response.setStatus(qrLoginData.getStatus()); - response.setExpiresIn(expiresIn); - response.setTenantId(qrLoginData.getTenantId()); - - // 如果已确认,返回token和用户信息 - if (QR_LOGIN_STATUS_CONFIRMED.equals(qrLoginData.getStatus())) { - response.setAccessToken(qrLoginData.getAccessToken()); - // 获取用户信息 - if (qrLoginData.getUserId() != null) { - User user = userService.getAllByUserId("" + qrLoginData.getUserId()); - if (user != null) { - // 清除敏感信息 - user.setPassword(null); - response.setUserInfo(user); - } + if (QR_LOGIN_STATUS_CONFIRMED.equals(qrLoginData.getStatus()) + && StrUtil.isBlank(qrLoginData.getAccessToken()) + && qrLoginData.getUserId() != null) { + User user = userService.getAllByUserId(String.valueOf(qrLoginData.getUserId())); + if (user != null) { + qrLoginData.setAccessToken(buildAccessToken(user)); + redisUtil.set(redisKey, qrLoginData, Math.max(expiresIn, 120L), TimeUnit.SECONDS); } - - // 确认后删除token,防止重复使用 -// redisUtil.delete(redisKey); } - return response; + return buildStatusResponse(qrLoginData, expiresIn); } @Override @@ -179,47 +151,35 @@ public class QrLoginServiceImpl implements QrLoginService { String redisKey = QR_LOGIN_TOKEN_KEY + token; QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class); - if (qrLoginData == null) { throw new RuntimeException("扫码登录token不存在或已过期"); } - // 检查是否过期 - if (DateUtil.date().after(DateUtil.parseDateTime(qrLoginData.getExpireTime()))) { + if (StrUtil.isBlank(qrLoginData.getExpireTime()) || DateUtil.date().after(DateUtil.parseDateTime(qrLoginData.getExpireTime()))) { redisUtil.delete(redisKey); throw new RuntimeException("扫码登录token已过期"); } - // 获取用户信息 User user = userService.getAllByUserId(String.valueOf(userId)); if (user == null) { throw new RuntimeException("用户不存在"); } - - // 检查用户状态 if (user.getStatus() != null && user.getStatus() != 0) { throw new RuntimeException("用户已被冻结"); } - // 生成JWT token - JwtSubject jwtSubject = new JwtSubject(user.getUsername(), user.getTenantId()); - String accessToken = JwtUtil.buildToken(jwtSubject, configProperties.getTokenExpireTime(), configProperties.getTokenKey()); - - // 更新扫码登录数据 + String accessToken = buildAccessToken(user); qrLoginData.setStatus(QR_LOGIN_STATUS_CONFIRMED); qrLoginData.setUserId(userId); qrLoginData.setUsername(user.getUsername()); qrLoginData.setAccessToken(accessToken); qrLoginData.setTenantId(user.getTenantId()); - // 更新Redis中的数据 - redisUtil.set(redisKey, qrLoginData, 60L, TimeUnit.SECONDS); // 给前端60秒时间获取token + qrLoginData.setNeedBindPhone(false); + qrLoginData.setMessage("登录成功"); + redisUtil.set(redisKey, qrLoginData, 120L, TimeUnit.SECONDS); log.info("用户 {} 确认扫码登录,token: {}", user.getUsername(), token); - - // 清除敏感信息 - user.setPassword(null); - - return new QrLoginStatusResponse(QR_LOGIN_STATUS_CONFIRMED, accessToken, user, 60L, user.getTenantId()); + return buildStatusResponse(qrLoginData, 120L); } @Override @@ -230,25 +190,21 @@ public class QrLoginServiceImpl implements QrLoginService { String redisKey = QR_LOGIN_TOKEN_KEY + token; QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class); - if (qrLoginData == null) { return false; } - // 检查是否过期 - if (DateUtil.date().after(DateUtil.parseDateTime(qrLoginData.getExpireTime()))) { + if (StrUtil.isBlank(qrLoginData.getExpireTime()) || DateUtil.date().after(DateUtil.parseDateTime(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 = (DateUtil.parseDateTime(qrLoginData.getExpireTime()).getTime() - DateUtil.date().getTime()) / 1000; + qrLoginData.setMessage("已识别扫码,等待公众号回调"); + long remainingSeconds = Math.max(1L, + (DateUtil.parseDateTime(qrLoginData.getExpireTime()).getTime() - DateUtil.date().getTime()) / 1000); redisUtil.set(redisKey, qrLoginData, remainingSeconds, TimeUnit.SECONDS); - log.info("扫码登录token {} 状态更新为已扫码", token); return true; } @@ -256,12 +212,82 @@ public class QrLoginServiceImpl implements QrLoginService { 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) { try { - // 使用公共的 WxService 获取 AccessToken String accessToken = wxService.getAccessToken(tenantId); if (StrUtil.isBlank(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; HashMap params = new HashMap<>(); params.put("path", "/pages/qr-login?token=" + token); - params.put("width", 430); // 二维码宽度,默认430px + params.put("width", 430); - // 调用微信API生成小程序码 byte[] qrCodeBytes = HttpRequest.post(apiUrl) - .body(JSON.toJSONString(params)) - .execute().bodyBytes(); + .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(); @@ -290,18 +313,48 @@ public class QrLoginServiceImpl implements QrLoginService { File file = FileUtil.writeBytes(qrCodeBytes, filePath); if (file != null && file.exists()) { - // 返回可访问的URL return configProperties.getFileServer() + "/qrcode/" + fileName; - } else { - throw new RuntimeException("保存小程序码文件失败"); } + throw new RuntimeException("保存小程序码文件失败"); } catch (Exception e) { log.error("生成微信小程序码失败: {}", e.getMessage(), e); 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; } + 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 public WechatScanResponse wechatScanConfirm(WechatScanRequest request) { String token = request.getToken(); @@ -329,13 +414,10 @@ public class QrLoginServiceImpl implements QrLoginService { String redisKey = QR_LOGIN_TOKEN_KEY + token; QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class); - if (qrLoginData == null) { return WechatScanResponse.notBound("二维码已过期,请刷新重试"); } - - // 检查是否过期 - if (DateUtil.date().after(DateUtil.parseDateTime(qrLoginData.getExpireTime()))) { + if (StrUtil.isBlank(qrLoginData.getExpireTime()) || DateUtil.date().after(DateUtil.parseDateTime(qrLoginData.getExpireTime()))) { redisUtil.delete(redisKey); return WechatScanResponse.notBound("二维码已过期,请刷新重试"); } @@ -344,7 +426,6 @@ public class QrLoginServiceImpl implements QrLoginService { String openId = request.getOpenId(); Integer tenantId = qrLoginData.getTenantId(); - // 如果没有直接传 unionId,但有 code,需要通过 code 获取 if (StrUtil.isBlank(unionId) && StrUtil.isNotBlank(request.getCode())) { try { JSONObject userAccessToken = wxService.getOfficialUserAccessToken(request.getCode(), tenantId); @@ -362,8 +443,6 @@ public class QrLoginServiceImpl implements QrLoginService { } User user = null; - - // 优先通过 unionId 查找用户 if (StrUtil.isNotBlank(unionId)) { user = userService.getOne(new LambdaQueryWrapper() .eq(User::getUnionid, unionId) @@ -372,9 +451,7 @@ public class QrLoginServiceImpl implements QrLoginService { log.info("通过 unionId {} 查找用户: {}", unionId, user != null ? user.getUsername() : "未找到"); } - // 如果通过 unionId 没找到,尝试通过 openId 查找 if (user == null && StrUtil.isNotBlank(openId)) { - // 尝试从 sys_user 表的 openid 字段查找 user = userService.getOne(new LambdaQueryWrapper() .eq(User::getOpenid, openId) .eq(User::getDeleted, 0) @@ -382,7 +459,6 @@ public class QrLoginServiceImpl implements QrLoginService { log.info("通过 openId {} 查找用户: {}", openId, user != null ? user.getUsername() : "未找到"); } - // 如果还没找到,尝试从 sys_user_oauth 表查找 if (user == null && (StrUtil.isNotBlank(unionId) || StrUtil.isNotBlank(openId))) { try { LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); @@ -410,30 +486,21 @@ public class QrLoginServiceImpl implements QrLoginService { if (user == null) { return WechatScanResponse.notBound("该微信未绑定平台账号,请先在平台注册并绑定微信"); } - - // 检查用户状态 if (user.getStatus() != null && user.getStatus() != 0) { return WechatScanResponse.notBound("账号已被冻结"); } - // 生成 JWT token - JwtSubject jwtSubject = new JwtSubject(user.getUsername(), user.getTenantId()); - String accessToken = JwtUtil.buildToken(jwtSubject, configProperties.getTokenExpireTime(), configProperties.getTokenKey()); - - // 更新扫码登录数据 + String accessToken = buildAccessToken(user); qrLoginData.setStatus(QR_LOGIN_STATUS_CONFIRMED); qrLoginData.setUserId(user.getUserId()); qrLoginData.setUsername(user.getUsername()); qrLoginData.setAccessToken(accessToken); qrLoginData.setTenantId(user.getTenantId()); - // 更新Redis中的数据 - redisUtil.set(redisKey, qrLoginData, 60L, TimeUnit.SECONDS); + qrLoginData.setNeedBindPhone(false); + qrLoginData.setMessage("登录成功"); + redisUtil.set(redisKey, qrLoginData, 120L, TimeUnit.SECONDS); - log.info("微信扫码登录成功,用户 {} 确认扫码登录,token: {}", user.getUsername(), token); - - // 清除敏感信息 user.setPassword(null); - return WechatScanResponse.success(accessToken, user, user.getTenantId()); } } diff --git a/src/main/java/com/gxwebsoft/common/core/constants/RedisConstants.java b/src/main/java/com/gxwebsoft/common/core/constants/RedisConstants.java index 68d8fef..605f2d9 100644 --- a/src/main/java/com/gxwebsoft/common/core/constants/RedisConstants.java +++ b/src/main/java/com/gxwebsoft/common/core/constants/RedisConstants.java @@ -33,6 +33,7 @@ public class RedisConstants { 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_BIND_PHONE = "bind_phone"; // 待绑定手机号 public static final String QR_LOGIN_STATUS_EXPIRED = "expired"; // 已过期 // 哗啦啦key diff --git a/src/main/java/com/gxwebsoft/common/system/controller/WxOfficialController.java b/src/main/java/com/gxwebsoft/common/system/controller/WxOfficialController.java index 1a8157c..28afc25 100644 --- a/src/main/java/com/gxwebsoft/common/system/controller/WxOfficialController.java +++ b/src/main/java/com/gxwebsoft/common/system/controller/WxOfficialController.java @@ -11,6 +11,10 @@ import com.alibaba.fastjson.JSONObject; import com.alipay.api.internal.util.file.IOUtils; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; 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.JSONUtil; import com.gxwebsoft.common.core.utils.RedisUtil;