diff --git a/pigx-common/pigx-common-core/src/main/java/com/pig4cloud/pigx/common/core/constant/SecurityConstants.java b/pigx-common/pigx-common-core/src/main/java/com/pig4cloud/pigx/common/core/constant/SecurityConstants.java index b9ab00ea4..4cd648fcf 100644 --- a/pigx-common/pigx-common-core/src/main/java/com/pig4cloud/pigx/common/core/constant/SecurityConstants.java +++ b/pigx-common/pigx-common-core/src/main/java/com/pig4cloud/pigx/common/core/constant/SecurityConstants.java @@ -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 */ diff --git a/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/handler/MiniAppLoginHandler.java b/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/handler/MiniAppLoginHandler.java index 76a3cef1d..ea3ff832b 100644 --- a/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/handler/MiniAppLoginHandler.java +++ b/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/handler/MiniAppLoginHandler.java @@ -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日 + * 微信小程序手机号登录处理器。 *

- * 微信小程序 + * 通过前端 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。 *

- * 通过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 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 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)); + } + }