From 3131b20c5bfe208ea73ca93f2864942818f36ef4 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 13:15:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(qrLogin):=20=E6=96=B0=E5=A2=9E=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E6=89=AB=E7=A0=81=E7=99=BB=E5=BD=95=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=8F=8AH5=E9=A1=B5=E9=9D=A2=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在QrLoginController中添加微信扫码登录确认接口和获取微信网页授权URL接口 - QrLoginGenerateResponse新增微信扫码登录H5页面URL及微信公众号AppID字段 - QrLoginService接口新增wechatScanConfirm方法实现微信扫码登录确认逻辑 - QrLoginServiceImpl完成基于unionId和openId的用户绑定验证与登录状态更新 - WxService扩展获取微信公众号AppID、AppSecret及AccessToken方法,支持多租户 - 新增WechatScanRequest和WechatScanResponse用于微信扫码登录请求和响应数据封装 - 生成微信扫码登录H5页面URL并包含token参数用于前端跳转确认登录 - 实现通过微信授权码获取用户unionId和openId,从而完成平台用户绑定验证 - 完善异常处理及日志记录,确保微信扫码登录流程稳定可靠 --- .../auto/controller/QrLoginController.java | 44 +++++ .../auto/dto/QrLoginGenerateResponse.java | 6 + .../gxwebsoft/auto/dto/WechatScanRequest.java | 31 ++++ .../auto/dto/WechatScanResponse.java | 47 ++++++ .../auto/service/QrLoginService.java | 10 ++ .../auto/service/impl/QrLoginServiceImpl.java | 142 ++++++++++++++++ .../common/system/service/WxService.java | 159 ++++++++++++++++++ 7 files changed, 439 insertions(+) create mode 100644 src/main/java/com/gxwebsoft/auto/dto/WechatScanRequest.java create mode 100644 src/main/java/com/gxwebsoft/auto/dto/WechatScanResponse.java diff --git a/src/main/java/com/gxwebsoft/auto/controller/QrLoginController.java b/src/main/java/com/gxwebsoft/auto/controller/QrLoginController.java index ad0de5b..360172f 100644 --- a/src/main/java/com/gxwebsoft/auto/controller/QrLoginController.java +++ b/src/main/java/com/gxwebsoft/auto/controller/QrLoginController.java @@ -3,6 +3,8 @@ 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.dto.WechatScanRequest; +import com.gxwebsoft.auto.dto.WechatScanResponse; import com.gxwebsoft.auto.service.QrLoginService; import com.gxwebsoft.common.core.web.BaseController; import com.gxwebsoft.common.core.web.ApiResult; @@ -28,6 +30,12 @@ public class QrLoginController extends BaseController { @Autowired private QrLoginService qrLoginService; + @Autowired + private com.gxwebsoft.common.system.service.WxService wxService; + + @Autowired + private javax.servlet.http.HttpServletRequest request; + /** * 生成扫码登录token */ @@ -85,4 +93,40 @@ public class QrLoginController extends BaseController { } } + /** + * 微信扫码登录确认(H5页面调用) + */ + @Operation(summary = "微信扫码登录确认") + @PostMapping("/wechat-scan") + public ApiResult wechatScanConfirm(@Valid @RequestBody WechatScanRequest request) { + try { + WechatScanResponse response = qrLoginService.wechatScanConfirm(request); + return success("操作成功", response); + } catch (Exception e) { + return fail(e.getMessage()); + } + } + + /** + * 获取微信网页授权 URL(用于 H5 扫码页面重定向) + */ + @Operation(summary = "获取微信网页授权URL") + @GetMapping("/wechat-oauth-url") + public ApiResult getWechatOAuthUrl(@Parameter(description = "扫码登录token") @RequestParam String token) { + try { + String appId = wxService.getOfficialAppId(getTenantId()); + // 回调地址,指向 H5 扫码确认页面 + String redirectUri = java.net.URLEncoder.encode( + "https://" + request.getHeader("Host") + "/wx-scan?token=" + token, + java.nio.charset.StandardCharsets.UTF_8); + // 构造微信 OAuth 授权 URL + String oauthUrl = String.format( + "https://open.weixin.qq.com/connect/oauth2/authorize?appid=%s&redirect_uri=%s&response_type=code&scope=snsapi_userinfo&state=%s#wechat_redirect", + appId, redirectUri, token); + return success("获取成功", oauthUrl); + } catch (Exception e) { + return fail(e.getMessage()); + } + } + } diff --git a/src/main/java/com/gxwebsoft/auto/dto/QrLoginGenerateResponse.java b/src/main/java/com/gxwebsoft/auto/dto/QrLoginGenerateResponse.java index 5c487d1..2e60d46 100644 --- a/src/main/java/com/gxwebsoft/auto/dto/QrLoginGenerateResponse.java +++ b/src/main/java/com/gxwebsoft/auto/dto/QrLoginGenerateResponse.java @@ -32,6 +32,12 @@ public class QrLoginGenerateResponse { @Schema(description = "过期时间(秒)") private Long expiresIn; + @Schema(description = "微信扫码登录H5页面URL") + private String wechatScanUrl; + + @Schema(description = "微信公众号AppID") + private String wechatAppId; + // 保持向后兼容的构造函数 public QrLoginGenerateResponse(String token, String qrCodeContent, Long expiresIn) { this.token = token; diff --git a/src/main/java/com/gxwebsoft/auto/dto/WechatScanRequest.java b/src/main/java/com/gxwebsoft/auto/dto/WechatScanRequest.java new file mode 100644 index 0000000..b8cc295 --- /dev/null +++ b/src/main/java/com/gxwebsoft/auto/dto/WechatScanRequest.java @@ -0,0 +1,31 @@ +package com.gxwebsoft.auto.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.NotBlank; + +/** + * 微信扫码登录请求(用于 H5 页面回调) + * + * @author 科技小王子 + * @since 2026-04-06 + */ +@Data +@Schema(description = "微信扫码登录请求") +public class WechatScanRequest { + + @Schema(description = "扫码登录token") + @NotBlank(message = "token不能为空") + private String token; + + @Schema(description = "微信公众号授权code") + private String code; + + @Schema(description = "微信unionId(如果已获取)") + private String unionId; + + @Schema(description = "微信openId") + private String openId; + +} diff --git a/src/main/java/com/gxwebsoft/auto/dto/WechatScanResponse.java b/src/main/java/com/gxwebsoft/auto/dto/WechatScanResponse.java new file mode 100644 index 0000000..36a21ac --- /dev/null +++ b/src/main/java/com/gxwebsoft/auto/dto/WechatScanResponse.java @@ -0,0 +1,47 @@ +package com.gxwebsoft.auto.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 微信扫码登录响应 + * + * @author 科技小王子 + * @since 2026-04-06 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "微信扫码登录响应") +public class WechatScanResponse { + + @Schema(description = "状态:success-登录成功,bind_required-需要绑定账号,not_bound-账号未绑定") + private String status; + + @Schema(description = "JWT访问令牌") + private String accessToken; + + @Schema(description = "用户信息") + private Object userInfo; + + @Schema(description = "提示信息") + private String message; + + @Schema(description = "租户ID") + private Integer tenantId; + + public static WechatScanResponse success(String accessToken, Object userInfo, Integer tenantId) { + return new WechatScanResponse("success", accessToken, userInfo, "登录成功", tenantId); + } + + public static WechatScanResponse needBind(String message) { + return new WechatScanResponse("bind_required", null, null, message, null); + } + + public static WechatScanResponse notBound(String message) { + return new WechatScanResponse("not_bound", null, null, message, null); + } + +} diff --git a/src/main/java/com/gxwebsoft/auto/service/QrLoginService.java b/src/main/java/com/gxwebsoft/auto/service/QrLoginService.java index fbd854b..659aba0 100644 --- a/src/main/java/com/gxwebsoft/auto/service/QrLoginService.java +++ b/src/main/java/com/gxwebsoft/auto/service/QrLoginService.java @@ -3,6 +3,8 @@ package com.gxwebsoft.auto.service; import com.gxwebsoft.auto.dto.QrLoginConfirmRequest; import com.gxwebsoft.auto.dto.QrLoginGenerateResponse; import com.gxwebsoft.auto.dto.QrLoginStatusResponse; +import com.gxwebsoft.auto.dto.WechatScanRequest; +import com.gxwebsoft.auto.dto.WechatScanResponse; /** * 扫码登录服务接口 @@ -43,4 +45,12 @@ public interface QrLoginService { */ boolean scanQrCode(String token); + /** + * 微信扫码登录确认(H5页面调用) + * + * @param request 微信扫码登录请求 + * @return WechatScanResponse + */ + WechatScanResponse wechatScanConfirm(WechatScanRequest request); + } 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 7c2b26d..8d068c2 100644 --- a/src/main/java/com/gxwebsoft/auto/service/impl/QrLoginServiceImpl.java +++ b/src/main/java/com/gxwebsoft/auto/service/impl/QrLoginServiceImpl.java @@ -6,6 +6,8 @@ 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.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.gxwebsoft.auto.dto.*; import com.gxwebsoft.auto.service.QrLoginService; import com.gxwebsoft.common.core.config.ConfigProperties; @@ -13,6 +15,8 @@ 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.entity.UserOauth; +import com.gxwebsoft.common.system.service.UserOauthService; import com.gxwebsoft.common.system.service.UserService; import com.gxwebsoft.common.system.service.WxService; import lombok.extern.slf4j.Slf4j; @@ -24,6 +28,7 @@ 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; /** * 扫码登录服务实现 @@ -47,6 +52,9 @@ public class QrLoginServiceImpl implements QrLoginService { @Autowired private WxService wxService; + @Autowired(required = false) + private UserOauthService userOauthService; + private static final String QR_LOGIN_TOKEN = "QR_LOGIN_TOKEN"; @Override @@ -88,6 +96,23 @@ public class QrLoginServiceImpl implements QrLoginService { // 小程序码生成失败不影响整体功能,继续返回其他信息 } + // 生成微信扫码登录 H5 页面 URL + try { + String appId = wxService.getOfficialAppId(tenantId); + String baseUrl = configProperties.getFileServer(); + if (StrUtil.isBlank(baseUrl)) { + baseUrl = "https://server.gxwebsoft.com"; + } + // 微信扫码后跳转的 H5 确认页面 + String wechatScanUrl = baseUrl + "/wx-scan?token=" + token; + response.setWechatScanUrl(wechatScanUrl); + response.setWechatAppId(appId); + log.info("生成微信扫码登录URL: {}", wechatScanUrl); + } catch (Exception e) { + log.warn("生成微信扫码URL失败: {}", e.getMessage()); + // 不影响整体功能 + } + return response; } @@ -290,4 +315,121 @@ public class QrLoginServiceImpl implements QrLoginService { } return uploadPath; } + + @Override + public WechatScanResponse wechatScanConfirm(WechatScanRequest request) { + String token = request.getToken(); + if (StrUtil.isBlank(token)) { + return WechatScanResponse.notBound("二维码参数错误"); + } + + 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()))) { + redisUtil.delete(redisKey); + return WechatScanResponse.notBound("二维码已过期,请刷新重试"); + } + + String unionId = request.getUnionId(); + 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); + unionId = userAccessToken.getString("unionid"); + openId = userAccessToken.getString("openid"); + log.info("通过授权码获取到 unionId: {}, openId: {}", unionId, openId); + } catch (Exception e) { + log.error("通过授权码获取用户信息失败: {}", e.getMessage()); + return WechatScanResponse.notBound("微信授权失败,请重试"); + } + } + + if (StrUtil.isBlank(unionId) && StrUtil.isBlank(openId)) { + return WechatScanResponse.notBound("无法获取微信用户信息"); + } + + User user = null; + + // 优先通过 unionId 查找用户 + if (StrUtil.isNotBlank(unionId)) { + user = userService.getOne(new LambdaQueryWrapper() + .eq(User::getUnionid, unionId) + .eq(User::getDeleted, 0) + .last("limit 1")); + 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) + .last("limit 1")); + 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<>(); + if (StrUtil.isNotBlank(unionId)) { + wrapper.eq(UserOauth::getUnionid, unionId); + } else { + wrapper.eq(UserOauth::getOauthId, openId); + } + wrapper.eq(UserOauth::getDeleted, 0); + + UserOauth userOauth = null; + if (userOauthService != null) { + userOauth = userOauthService.getOne(wrapper); + } + + if (userOauth != null && userOauth.getUserId() != null) { + user = userService.getAllByUserId(String.valueOf(userOauth.getUserId())); + log.info("通过 UserOauth 查找到用户: {}", user != null ? user.getUsername() : "未找到"); + } + } catch (Exception e) { + log.error("通过 UserOauth 查找用户失败: {}", e.getMessage()); + } + } + + 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()); + + // 更新扫码登录数据 + 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); + + 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/system/service/WxService.java b/src/main/java/com/gxwebsoft/common/system/service/WxService.java index e9b445c..eb3e9f9 100644 --- a/src/main/java/com/gxwebsoft/common/system/service/WxService.java +++ b/src/main/java/com/gxwebsoft/common/system/service/WxService.java @@ -2,6 +2,7 @@ package com.gxwebsoft.common.system.service; import cn.hutool.core.util.StrUtil; import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpUtil; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONObject; import lombok.extern.slf4j.Slf4j; @@ -9,6 +10,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; +import java.util.HashMap; import java.util.concurrent.TimeUnit; /** @@ -28,6 +30,7 @@ public class WxService { private RedisTemplate redisTemplate; private static final String ACCESS_TOKEN_KEY = "WX_ACCESS_TOKEN"; + private static final String MP_OFFICIAL_ACCESS_TOKEN_KEY = "MP_OFFICIAL_ACCESS_TOKEN"; /** * 获取微信AccessToken(使用默认租户) @@ -107,4 +110,160 @@ public class WxService { throw new RuntimeException("获取微信AccessToken失败: " + e.getMessage()); } } + + /** + * 获取微信公众号 AppID + */ + public String getOfficialAppId() { + return getOfficialAppId(null); + } + + /** + * 获取微信公众号 AppID(支持指定租户ID) + */ + public String getOfficialAppId(Integer tenantId) { + if (tenantId == null) { + tenantId = 10048; + } + JSONObject setting = settingService.getBySettingKey("wx-official"); + if (setting == null) { + throw new RuntimeException("请先配置微信公众号"); + } + String appId = setting.getString("appId"); + if (StrUtil.isBlank(appId)) { + throw new RuntimeException("微信公众号配置不完整"); + } + return appId; + } + + /** + * 获取微信公众号 AppSecret + */ + public String getOfficialAppSecret() { + return getOfficialAppSecret(null); + } + + /** + * 获取微信公众号 AppSecret(支持指定租户ID) + */ + public String getOfficialAppSecret(Integer tenantId) { + if (tenantId == null) { + tenantId = 10048; + } + JSONObject setting = settingService.getBySettingKey("wx-official"); + if (setting == null) { + throw new RuntimeException("请先配置微信公众号"); + } + String appSecret = setting.getString("appSecret"); + if (StrUtil.isBlank(appSecret)) { + throw new RuntimeException("微信公众号配置不完整"); + } + return appSecret; + } + + /** + * 获取微信公众号 AccessToken(用于 API 调用,非用户授权) + */ + public String getOfficialAccessToken() { + return getOfficialAccessToken(null); + } + + /** + * 获取微信公众号 AccessToken(支持指定租户ID) + */ + public String getOfficialAccessToken(Integer tenantId) { + if (tenantId == null) { + tenantId = 10048; + } + + String key = MP_OFFICIAL_ACCESS_TOKEN_KEY + ":" + tenantId; + + // 从缓存获取 + 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)) { + log.debug("从缓存获取公众号access_token: {}", accessToken); + return accessToken; + } + } catch (Exception e) { + log.warn("解析缓存的公众号access_token失败: {}", e.getMessage()); + redisTemplate.delete(key); + } + } + + // 缓存中没有,重新获取 + try { + String appId = getOfficialAppId(tenantId); + String appSecret = getOfficialAppSecret(tenantId); + + String apiUrl = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=" + + appId + "&secret=" + appSecret; + String response = HttpUtil.get(apiUrl); + + 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); + log.info("获取新的公众号access_token成功: {}", accessToken); + return accessToken; + } else { + throw new RuntimeException("获取公众号AccessToken失败: " + response); + } + } catch (Exception e) { + log.error("获取微信公众号AccessToken失败: {}", e.getMessage(), e); + throw new RuntimeException("获取微信公众号AccessToken失败: " + e.getMessage()); + } + } + + /** + * 通过授权码获取用户 AccessToken(用户授权后使用) + * 用于 OAuth2 授权流程,获取用户信息 + */ + public JSONObject getOfficialUserAccessToken(String code, Integer tenantId) { + if (tenantId == null) { + tenantId = 10048; + } + try { + String appId = getOfficialAppId(tenantId); + String appSecret = getOfficialAppSecret(tenantId); + + String apiUrl = "https://api.weixin.qq.com/sns/oauth2/access_token?appid=" + + appId + "&secret=" + appSecret + "&code=" + code + "&grant_type=authorization_code"; + + String response = HttpUtil.get(apiUrl); + log.info("获取用户AccessToken响应: {}", response); + + JSONObject result = JSON.parseObject(response); + String accessToken = result.getString("access_token"); + if (StrUtil.isBlank(accessToken)) { + String errMsg = result.getString("errmsg"); + throw new RuntimeException("获取用户授权失败: " + errMsg); + } + return result; + } catch (Exception e) { + log.error("获取用户授权AccessToken失败: {}", e.getMessage(), e); + throw new RuntimeException("获取用户授权失败: " + e.getMessage()); + } + } + + /** + * 获取微信公众号用户信息 + */ + public JSONObject getOfficialUserInfo(String accessToken, String openId) { + try { + String apiUrl = "https://api.weixin.qq.com/sns/userinfo?access_token=" + accessToken + "&openid=" + openId + "&lang=zh_CN"; + String response = HttpUtil.get(apiUrl); + log.info("获取用户信息响应: {}", response); + return JSON.parseObject(response); + } catch (Exception e) { + log.error("获取用户信息失败: {}", e.getMessage(), e); + throw new RuntimeException("获取用户信息失败: " + e.getMessage()); + } + } }