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:
PIGCLOUD committed 2026-05-28 14:41:15 +08:00
1 parent dd49790ceb
commit f3c0b2034c
2 files changed
+176 -65

No files matched your search

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