feat(qrLogin): 新增微信扫码登录功能及H5页面支持

- 在QrLoginController中添加微信扫码登录确认接口和获取微信网页授权URL接口
- QrLoginGenerateResponse新增微信扫码登录H5页面URL及微信公众号AppID字段
- QrLoginService接口新增wechatScanConfirm方法实现微信扫码登录确认逻辑
- QrLoginServiceImpl完成基于unionId和openId的用户绑定验证与登录状态更新
- WxService扩展获取微信公众号AppID、AppSecret及AccessToken方法,支持多租户
- 新增WechatScanRequest和WechatScanResponse用于微信扫码登录请求和响应数据封装
- 生成微信扫码登录H5页面URL并包含token参数用于前端跳转确认登录
- 实现通过微信授权码获取用户unionId和openId,从而完成平台用户绑定验证
- 完善异常处理及日志记录,确保微信扫码登录流程稳定可靠
This commit is contained in:
2026-04-06 13:15:28 +08:00
parent 88b2e9977c
commit 3131b20c5b
7 changed files with 439 additions and 0 deletions

View File

@@ -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());
}
}
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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<User>()
.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<User>()
.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<UserOauth> 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());
}
}

View File

@@ -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<String, String> 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());
}
}
}