mirror of
http://git.pig4cloud.com/pig/pigx.git
synced 2026-11-13 22:09:00 +08:00
feat(pigx-upms-biz): 重构微信小程序登录,由 openid 改为手机号登录
- 修改 `MiniAppLoginHandler` 登录流程:使用前端 `getPhoneNumber` 返回的 code 换取手机号,并通过手机号获取用户信息;绑定逻辑改为写入手机号字段 - 引入微信接口调用凭证(access_token)的获取与 Redis 缓存机制,减少重复请求 - 新增 `SecurityConstants` 中 `MINI_APP_ACCESS_TOKEN_URL` 和 `MINI_APP_PHONE_NUMBER_URL` 常量 - 移除自动创建小程序用户逻辑,未绑定时直接返回 null - 提取微信响应校验、缓存读取/写入、HTTP 请求等方法,便于单元测试覆盖
This commit is contained in:
1 parent
dd49790ceb
commit
f3c0b2034c
2 files changed
+176
-65
No files matched your search
+11
@@ -126,6 +126,17 @@ public interface SecurityConstants {
|
||||
String MINI_APP_AUTHORIZATION_CODE_URL = "https://api.weixin.qq.com/sns/jscode2session"
|
||||
+ "?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code";
|
||||
|
||||
/**
|
||||
* 微信小程序接口调用凭证
|
||||
*/
|
||||
String MINI_APP_ACCESS_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token"
|
||||
+ "?grant_type=client_credential&appid=%s&secret=%s";
|
||||
|
||||
/**
|
||||
* 微信小程序手机号获取接口
|
||||
*/
|
||||
String MINI_APP_PHONE_NUMBER_URL = "https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=%s";
|
||||
|
||||
/**
|
||||
* 码云获取token
|
||||
*/
|
||||
|
||||
+165
-65
@@ -18,6 +18,7 @@
|
||||
package com.pig4cloud.pigx.admin.handler;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.http.HttpRequest;
|
||||
import cn.hutool.http.HttpUtil;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
@@ -29,132 +30,231 @@ import com.pig4cloud.pigx.admin.api.entity.SysSocialDetails;
|
||||
import com.pig4cloud.pigx.admin.api.entity.SysUser;
|
||||
import com.pig4cloud.pigx.admin.mapper.SysSocialDetailsMapper;
|
||||
import com.pig4cloud.pigx.admin.service.SysUserService;
|
||||
import com.pig4cloud.pigx.common.core.constant.CacheConstants;
|
||||
import com.pig4cloud.pigx.common.core.constant.SecurityConstants;
|
||||
import com.pig4cloud.pigx.common.core.constant.enums.LoginTypeEnum;
|
||||
import com.pig4cloud.pigx.common.core.exception.CheckedException;
|
||||
import com.pig4cloud.pigx.common.core.util.MsgUtils;
|
||||
import com.pig4cloud.pigx.common.core.util.R;
|
||||
import com.pig4cloud.pigx.common.data.cache.RedisUtils;
|
||||
import com.pig4cloud.pigx.common.data.tenant.TenantContextHolder;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* @author lengleng
|
||||
* @date 2019年11月02日
|
||||
* 微信小程序手机号登录处理器。
|
||||
* <p>
|
||||
* 微信小程序
|
||||
* 通过前端 wx.getPhoneNumber 返回的 code,换取用户手机号并完成登录绑定。
|
||||
* access_token 获取后会缓存至 Redis,减少对微信接口的重复调用。
|
||||
*
|
||||
* @author lengleng
|
||||
* @date 2026-05-26
|
||||
*/
|
||||
@Slf4j
|
||||
@Component("MINI")
|
||||
@AllArgsConstructor
|
||||
public class MiniAppLoginHandler extends AbstractLoginHandler {
|
||||
|
||||
/**
|
||||
* 微信 access_token 默认有效期,单位秒(官方文档值)。
|
||||
*/
|
||||
private static final long ACCESS_TOKEN_DEFAULT_EXPIRES_IN = 7200L;
|
||||
|
||||
/**
|
||||
* 提前过期缓冲时间,单位秒,避免在恰好过期时使用即将失效的 token。
|
||||
*/
|
||||
private static final long ACCESS_TOKEN_EXPIRES_BUFFER = 300L;
|
||||
|
||||
/**
|
||||
* Redis 缓存 access_token 的最小 TTL,单位秒,防止因 expires_in 异常导致缓存永久有效。
|
||||
*/
|
||||
private static final long ACCESS_TOKEN_MIN_TTL = 60L;
|
||||
|
||||
private final SysUserService sysUserService;
|
||||
|
||||
private final SysSocialDetailsMapper sysSocialDetailsMapper;
|
||||
|
||||
/**
|
||||
* 小程序登录传入code
|
||||
* 小程序手机号登录传入 getPhoneNumber 返回的 code。
|
||||
* <p>
|
||||
* 通过code 调用qq 获取唯一标识
|
||||
* 该 code 不是 wx.login 返回的登录 code,只能用于服务端换取手机号,有效期 5 分钟且一次性。
|
||||
*
|
||||
* @param code
|
||||
* @return
|
||||
* @param code 前端 wx.getPhoneNumber 回调中的动态令牌,不允许为空
|
||||
* @return 用户手机号(优先返回不含国家码的纯手机号)
|
||||
*/
|
||||
@Override
|
||||
public String identify(String code) {
|
||||
SysSocialDetails condition = new SysSocialDetails();
|
||||
condition.setType(LoginTypeEnum.MINI_APP.getType());
|
||||
SysSocialDetails socialDetails = sysSocialDetailsMapper.selectOne(new QueryWrapper<>(condition));
|
||||
|
||||
String url = String.format(SecurityConstants.MINI_APP_AUTHORIZATION_CODE_URL, socialDetails.getAppId(),
|
||||
socialDetails.getAppSecret(), code);
|
||||
String result = HttpUtil.get(url);
|
||||
log.debug("微信小程序响应报文:{}", result);
|
||||
JSONObject resultJsonObj = JSONUtil.parseObj(result);
|
||||
|
||||
if (resultJsonObj.containsKey("errcode")) {
|
||||
log.error("微信小程序登录失败:{}", result);
|
||||
throw new CheckedException(MsgUtils.getMessage(UpmsErrorCodes.SYS_MINIAPP_LOGIN_FAILED));
|
||||
if (StrUtil.isBlank(code)) {
|
||||
log.warn("小程序手机号登录 code 为空");
|
||||
throw miniAppLoginFailed();
|
||||
}
|
||||
|
||||
return resultJsonObj.getStr("openid");
|
||||
SysSocialDetails socialDetails = getMiniAppSocialDetails();
|
||||
String accessToken = getMiniAppAccessToken(socialDetails);
|
||||
String result = postJson(String.format(SecurityConstants.MINI_APP_PHONE_NUMBER_URL, accessToken),
|
||||
JSONUtil.createObj().set("code", code).toString());
|
||||
log.debug("微信小程序手机号响应报文:{}", result);
|
||||
JSONObject resultJsonObj = JSONUtil.parseObj(result);
|
||||
|
||||
checkWechatResponse(resultJsonObj, result);
|
||||
JSONObject phoneInfo = resultJsonObj.getJSONObject("phone_info");
|
||||
if (phoneInfo == null) {
|
||||
log.error("微信小程序手机号响应缺少 phone_info:{}", result);
|
||||
throw miniAppLoginFailed();
|
||||
}
|
||||
String purePhoneNumber = phoneInfo.getStr("purePhoneNumber");
|
||||
if (StrUtil.isNotBlank(purePhoneNumber)) {
|
||||
return purePhoneNumber;
|
||||
}
|
||||
String phoneNumber = phoneInfo.getStr("phoneNumber");
|
||||
if (StrUtil.isBlank(phoneNumber)) {
|
||||
log.error("微信小程序手机号响应缺少手机号:{}", result);
|
||||
throw miniAppLoginFailed();
|
||||
}
|
||||
return phoneNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据openId获取用户信息
|
||||
* 根据手机号获取用户信息。
|
||||
*
|
||||
* @param openId 用户openId
|
||||
* @param phone 用户手机号
|
||||
* @return 用户信息对象,未找到时返回null
|
||||
*/
|
||||
@Override
|
||||
public UserInfo info(String openId) {
|
||||
if (StrUtil.isBlank(openId)) {
|
||||
log.warn("小程序openId为空,无法获取用户信息");
|
||||
public UserInfo info(String phone) {
|
||||
if (StrUtil.isBlank(phone)) {
|
||||
log.warn("小程序手机号为空,无法获取用户信息");
|
||||
return null;
|
||||
}
|
||||
|
||||
UserDTO userDTO = new UserDTO();
|
||||
userDTO.setMiniOpenid(openId);
|
||||
userDTO.setPhone(phone);
|
||||
|
||||
R<UserInfo> userInfoR = sysUserService.getUserInfo(userDTO);
|
||||
|
||||
if (userInfoR.getData() == null) {
|
||||
log.info("小程序不存在用户:{}, 开始自动创建", openId);
|
||||
return createMiniAppUser(openId);
|
||||
log.info("小程序手机号不存在用户:{}", phone);
|
||||
return null;
|
||||
}
|
||||
|
||||
return userInfoR.getData();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定逻辑
|
||||
* 绑定逻辑:将手机号写入用户记录。
|
||||
*
|
||||
* @param user 用户实体
|
||||
* @param identify 渠道返回唯一标识
|
||||
* @return
|
||||
* @param identify 微信返回的手机号
|
||||
* @return 始终返回 true
|
||||
*/
|
||||
@Override
|
||||
public Boolean bind(SysUser user, String identify) {
|
||||
user.setMiniOpenid(identify);
|
||||
user.setPhone(identify);
|
||||
sysUserService.updateById(user);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动创建小程序用户
|
||||
*
|
||||
* @param openId 小程序openId
|
||||
* @return 创建成功返回用户信息,失败返回null
|
||||
*/
|
||||
private UserInfo createMiniAppUser(String openId) {
|
||||
try {
|
||||
log.info("开始为openId[{}]创建小程序用户", openId);
|
||||
|
||||
// 构造用户DTO
|
||||
UserDTO newUser = new UserDTO();
|
||||
newUser.setMiniOpenid(openId);
|
||||
newUser.setUsername(openId);
|
||||
newUser.setPassword(openId);
|
||||
newUser.setTenantId(TenantContextHolder.getTenantId());
|
||||
// 保存用户
|
||||
sysUserService.saveUser(newUser);
|
||||
log.info("小程序用户创建成功,openId: {}", openId);
|
||||
|
||||
// 重新获取用户信息
|
||||
UserDTO queryDTO = new UserDTO();
|
||||
queryDTO.setMiniOpenid(openId);
|
||||
R<UserInfo> userInfoR = sysUserService.getUserInfo(queryDTO);
|
||||
|
||||
if (userInfoR.getData() != null) {
|
||||
return userInfoR.getData();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("创建小程序用户异常,openId: {}, 错误信息: {}", openId, e.getMessage(), e);
|
||||
private SysSocialDetails getMiniAppSocialDetails() {
|
||||
SysSocialDetails condition = new SysSocialDetails();
|
||||
condition.setType(LoginTypeEnum.MINI_APP.getType());
|
||||
SysSocialDetails socialDetails = sysSocialDetailsMapper.selectOne(new QueryWrapper<>(condition));
|
||||
if (socialDetails == null || StrUtil.isBlank(socialDetails.getAppId())
|
||||
|| StrUtil.isBlank(socialDetails.getAppSecret())) {
|
||||
log.error("微信小程序密钥配置缺失");
|
||||
throw miniAppLoginFailed();
|
||||
}
|
||||
|
||||
return null;
|
||||
return socialDetails;
|
||||
}
|
||||
|
||||
private String getMiniAppAccessToken(SysSocialDetails socialDetails) {
|
||||
String cacheKey = getAccessTokenCacheKey(socialDetails);
|
||||
String cachedAccessToken = getCachedAccessToken(cacheKey);
|
||||
if (StrUtil.isNotBlank(cachedAccessToken)) {
|
||||
return cachedAccessToken;
|
||||
}
|
||||
|
||||
String result = get(String.format(SecurityConstants.MINI_APP_ACCESS_TOKEN_URL, socialDetails.getAppId(),
|
||||
socialDetails.getAppSecret()));
|
||||
log.debug("微信小程序 access_token 响应报文:{}", result);
|
||||
JSONObject resultJsonObj = JSONUtil.parseObj(result);
|
||||
checkWechatResponse(resultJsonObj, result);
|
||||
|
||||
String accessToken = resultJsonObj.getStr("access_token");
|
||||
if (StrUtil.isBlank(accessToken)) {
|
||||
log.error("微信小程序 access_token 为空:{}", result);
|
||||
throw miniAppLoginFailed();
|
||||
}
|
||||
|
||||
Long expiresIn = resultJsonObj.getLong("expires_in");
|
||||
long ttl = Math.max(ACCESS_TOKEN_MIN_TTL,
|
||||
(expiresIn == null ? ACCESS_TOKEN_DEFAULT_EXPIRES_IN : expiresIn) - ACCESS_TOKEN_EXPIRES_BUFFER);
|
||||
cacheAccessToken(cacheKey, accessToken, ttl);
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
private String getAccessTokenCacheKey(SysSocialDetails socialDetails) {
|
||||
return String.format("%sminiapp:access_token:%s:%s", CacheConstants.GLOBALLY, TenantContextHolder.getTenantId(),
|
||||
socialDetails.getAppId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Redis 读取已缓存的 access_token,供子类在测试中覆盖。
|
||||
*
|
||||
* @param cacheKey Redis 键
|
||||
* @return 缓存的 access_token,不存在时返回 null
|
||||
*/
|
||||
protected String getCachedAccessToken(String cacheKey) {
|
||||
return RedisUtils.get(cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 access_token 写入 Redis,供子类在测试中覆盖。
|
||||
*
|
||||
* @param cacheKey Redis 键
|
||||
* @param accessToken 待缓存的 access_token
|
||||
* @param ttl 过期时间,单位秒
|
||||
*/
|
||||
protected void cacheAccessToken(String cacheKey, String accessToken, long ttl) {
|
||||
RedisUtils.set(cacheKey, accessToken, ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起 HTTP GET 请求,供子类在测试中覆盖。
|
||||
*
|
||||
* @param url 请求地址
|
||||
* @return 响应体字符串
|
||||
*/
|
||||
protected String get(String url) {
|
||||
return HttpUtil.get(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起 HTTP POST JSON 请求,供子类在测试中覆盖。
|
||||
*
|
||||
* @param url 请求地址
|
||||
* @param body JSON 请求体字符串
|
||||
* @return 响应体字符串
|
||||
*/
|
||||
protected String postJson(String url, String body) {
|
||||
return HttpRequest.post(url).header("Content-Type", "application/json").body(body).execute().body();
|
||||
}
|
||||
|
||||
private void checkWechatResponse(JSONObject resultJsonObj, String rawResult) {
|
||||
Integer errorCode = resultJsonObj.getInt("errcode");
|
||||
if (errorCode != null && errorCode != 0) {
|
||||
log.error("微信小程序登录失败:{}", rawResult);
|
||||
throw miniAppLoginFailed();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造小程序登录失败异常,供子类在测试中覆盖或统一替换错误信息。
|
||||
*
|
||||
* @return 标准小程序登录失败异常
|
||||
*/
|
||||
protected CheckedException miniAppLoginFailed() {
|
||||
return new CheckedException(MsgUtils.getMessage(UpmsErrorCodes.SYS_MINIAPP_LOGIN_FAILED));
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user