From 42052f7bf5804f9e5835fc1f36964ab7842a4135 Mon Sep 17 00:00:00 2001 From: seewe <3267948089@qq.com> Date: Sun, 30 Aug 2026 18:07:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E5=B0=8F=E7=A8=8B=E5=BA=8F?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E6=94=AF=E6=8C=81=E9=85=8D=E7=BD=AE=E5=8C=96?= =?UTF-8?q?=E4=B8=8E=E6=89=8B=E6=9C=BA=E5=8F=B7=E4=B8=80=E9=94=AE=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E8=87=AA=E5=8A=A8=E6=B3=A8=E5=86=8C=20/=20Add=20confi?= =?UTF-8?q?gurable=20xcx=20login=20with=20phone=20auto-registration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 小程序 appid/密钥支持参数管理 sys.account.xcxApps(JSON 数组,多小程序)优先,yml security.xcx.apps 兜底 - 登录新增 phoneCode 字段,调用微信 getuserphonenumber 解析真实手机号,stable_token 按小程序缓存至 redis - 首次登录自动注册:以授权手机号为账号(已有同手机号账号则直接绑定),写入 sys_social 绑定 openid - 自动注册默认昵称/初始密码(sys.user.initPassword)/头像/性别,用户类型 app_user --- .../dromara/system/api/RemoteUserService.java | 5 +- .../org/dromara/auth/form/XcxLoginBody.java | 5 + .../auth/properties/XcxProperties.java | 27 ++++ .../auth/service/impl/XcxAuthStrategy.java | 132 +++++++++++++++++- .../system/dubbo/RemoteUserServiceImpl.java | 75 +++++++++- script/config/nacos/ruoyi-auth.yml | 5 + 6 files changed, 238 insertions(+), 11 deletions(-) create mode 100644 ruoyi-auth/src/main/java/org/dromara/auth/properties/XcxProperties.java diff --git a/ruoyi-api/ruoyi-api-system/src/main/java/org/dromara/system/api/RemoteUserService.java b/ruoyi-api/ruoyi-api-system/src/main/java/org/dromara/system/api/RemoteUserService.java index fad5b2a1f..f113c45be 100644 --- a/ruoyi-api/ruoyi-api-system/src/main/java/org/dromara/system/api/RemoteUserService.java +++ b/ruoyi-api/ruoyi-api-system/src/main/java/org/dromara/system/api/RemoteUserService.java @@ -51,12 +51,13 @@ public interface RemoteUserService { LoginUser getUserInfoByEmail(String email) throws UserException; /** - * 通过openid查询用户信息 + * 通过openid查询小程序用户信息,用户不存在时自动注册 * * @param openid openid + * @param phone 手机号一键登录获取的真实手机号,首次登录自动注册时作为账号,可为空 * @return 结果 */ - XcxLoginUser getUserInfoByOpenid(String openid) throws UserException; + XcxLoginUser getUserInfoByOpenid(String openid, String phone) throws UserException; /** * 注册用户信息 diff --git a/ruoyi-auth/src/main/java/org/dromara/auth/form/XcxLoginBody.java b/ruoyi-auth/src/main/java/org/dromara/auth/form/XcxLoginBody.java index 3c8a7f536..b52a7ea18 100644 --- a/ruoyi-auth/src/main/java/org/dromara/auth/form/XcxLoginBody.java +++ b/ruoyi-auth/src/main/java/org/dromara/auth/form/XcxLoginBody.java @@ -25,4 +25,9 @@ public class XcxLoginBody extends LoginBody { @NotBlank(message = "{xcx.code.not.blank}") private String xcxCode; + /** + * 手机号一键登录 code(button open-type="getPhoneNumber" 获取),首次登录自动注册时必传 + */ + private String phoneCode; + } diff --git a/ruoyi-auth/src/main/java/org/dromara/auth/properties/XcxProperties.java b/ruoyi-auth/src/main/java/org/dromara/auth/properties/XcxProperties.java new file mode 100644 index 000000000..a0bea1760 --- /dev/null +++ b/ruoyi-auth/src/main/java/org/dromara/auth/properties/XcxProperties.java @@ -0,0 +1,27 @@ +package org.dromara.auth.properties; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.context.annotation.Configuration; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 小程序登录配置 + * + * @author seewis + */ +@Data +@Configuration +@RefreshScope +@ConfigurationProperties(prefix = "security.xcx") +public class XcxProperties { + + /** + * 小程序 appid 与密钥映射,key 为 appid,value 为 appSecret,支持配置多个小程序 + */ + private Map apps = new LinkedHashMap<>(); + +} diff --git a/ruoyi-auth/src/main/java/org/dromara/auth/service/impl/XcxAuthStrategy.java b/ruoyi-auth/src/main/java/org/dromara/auth/service/impl/XcxAuthStrategy.java index 5ba7829d6..bca46bec8 100644 --- a/ruoyi-auth/src/main/java/org/dromara/auth/service/impl/XcxAuthStrategy.java +++ b/ruoyi-auth/src/main/java/org/dromara/auth/service/impl/XcxAuthStrategy.java @@ -2,6 +2,10 @@ package org.dromara.auth.service.impl; import cn.dev33.satoken.stp.StpUtil; import cn.dev33.satoken.stp.parameter.SaLoginParameter; +import cn.hutool.http.HttpUtil; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import lombok.Data; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import me.zhyd.oauth.config.AuthConfig; @@ -14,17 +18,27 @@ import me.zhyd.oauth.request.AuthWechatMiniProgramRequest; import org.apache.dubbo.config.annotation.DubboReference; import org.dromara.auth.domain.vo.LoginVo; import org.dromara.auth.form.XcxLoginBody; +import org.dromara.auth.properties.XcxProperties; import org.dromara.auth.service.IAuthStrategy; import org.dromara.auth.service.SysLoginService; import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.constant.GlobalConstants; import org.dromara.common.core.utils.ValidatorUtils; +import org.dromara.common.core.utils.StringUtils; import org.dromara.common.json.utils.JsonUtils; +import org.dromara.common.redis.utils.RedisUtils; import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.system.api.RemoteConfigService; import org.dromara.system.api.RemoteUserService; import org.dromara.system.api.domain.vo.RemoteClientVo; import org.dromara.system.api.model.XcxLoginUser; import org.springframework.stereotype.Service; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + /** * 邮件认证策略 * @@ -36,6 +50,10 @@ import org.springframework.stereotype.Service; public class XcxAuthStrategy implements IAuthStrategy { private final SysLoginService loginService; + private final XcxProperties xcxProperties; + + @DubboReference + private final RemoteConfigService remoteConfigService; @DubboReference private RemoteUserService remoteUserService; @@ -48,10 +66,16 @@ public class XcxAuthStrategy implements IAuthStrategy { String xcxCode = loginBody.getXcxCode(); // 多个小程序识别使用 String appid = loginBody.getAppid(); + // 手机号一键登录 code(button open-type="getPhoneNumber" 获取),首次登录自动注册时需要 + String phoneCode = loginBody.getPhoneCode(); + + Map apps = loadApps(); + String useAppid = resolveAppid(apps, appid); + String appSecret = resolveAppSecret(apps, useAppid); // 校验 appid + appsrcret + xcxCode 调用登录凭证校验接口 获取 session_key 与 openid AuthRequest authRequest = new AuthWechatMiniProgramRequest(AuthConfig.builder() - .clientId(appid).clientSecret("自行填写密钥 可根据不同appid填入不同密钥") + .clientId(useAppid).clientSecret(appSecret) .ignoreCheckRedirectUri(true).ignoreCheckState(true).build()); AuthCallback authCallback = new AuthCallback(); authCallback.setCode(xcxCode); @@ -65,8 +89,13 @@ public class XcxAuthStrategy implements IAuthStrategy { } else { throw new ServiceException(resp.getMsg()); } + // 手机号一键登录:获取用户授权的真实手机号,首次登录自动注册时作为账号 + String phone = null; + if (StringUtils.isNotBlank(phoneCode)) { + phone = getPhoneNumber(useAppid, appSecret, phoneCode); + } // todo getUserInfoByOpenid 方法内部查询逻辑需要自行根据业务实现 - XcxLoginUser loginUser = remoteUserService.getUserInfoByOpenid(openid); + XcxLoginUser loginUser = remoteUserService.getUserInfoByOpenid(openid, phone); loginUser.setClientKey(client.getClientKey()); loginUser.setDeviceType(client.getDeviceType()); @@ -82,4 +111,103 @@ public class XcxAuthStrategy implements IAuthStrategy { return loginVo; } + /** + * 加载小程序配置:优先取参数管理中的 sys.account.xcxApps(JSON 数组),未配置时回退 yml 配置 + */ + private Map loadApps() { + String configValue = remoteConfigService.getConfigValue("sys.account.xcxApps"); + if (StringUtils.isNotBlank(configValue)) { + try { + Map apps = new LinkedHashMap<>(); + for (XcxApp app : JsonUtils.parseArray(configValue, XcxApp.class)) { + if (StringUtils.isNotBlank(app.getAppid()) && StringUtils.isNotBlank(app.getAppSecret())) { + apps.put(app.getAppid(), app.getAppSecret()); + } + } + if (!apps.isEmpty()) { + return apps; + } + } catch (Exception e) { + log.warn("解析参数 sys.account.xcxApps 失败,回退使用 yml 配置 security.xcx.apps", e); + } + } + return xcxProperties.getApps(); + } + + /** + * 解析实际使用的 appid:未传且仅配置了一个小程序时自动使用唯一配置 + */ + private String resolveAppid(Map apps, String appid) { + if (StringUtils.isNotBlank(appid)) { + return appid; + } + if (apps.size() == 1) { + return apps.keySet().iterator().next(); + } + throw new ServiceException("未配置小程序 appid,请在参数 sys.account.xcxApps 或 security.xcx.apps 中配置"); + } + + /** + * 解析小程序密钥 + */ + private String resolveAppSecret(Map apps, String appid) { + String secret = apps.get(appid); + if (StringUtils.isBlank(secret)) { + throw new ServiceException("未配置小程序 " + appid + " 对应的密钥,请在参数 sys.account.xcxApps 中配置"); + } + return secret; + } + + /** + * 手机号一键登录:调用微信 getuserphonenumber 接口获取用户授权的真实手机号 + */ + private String getPhoneNumber(String appid, String appSecret, String phoneCode) { + String accessToken = getAccessToken(appid, appSecret); + JSONObject body = new JSONObject(); + body.set("code", phoneCode); + String response = HttpUtil.post( + "https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=" + accessToken, + body.toString()); + JSONObject json = JSONUtil.parseObj(response); + if (json.getInt("errcode", 0) != 0) { + log.warn("微信手机号获取失败: {}", response); + throw new ServiceException("手机号获取失败: " + json.getStr("errmsg")); + } + return json.getJSONObject("phone_info").getStr("purePhoneNumber"); + } + + /** + * 获取微信接口调用凭证 stable_token,缓存至 redis,过期前 5 分钟刷新 + */ + private String getAccessToken(String appid, String appSecret) { + String cacheKey = GlobalConstants.GLOBAL_REDIS_KEY + "xcx:access_token:" + appid; + String accessToken = RedisUtils.getCacheObject(cacheKey); + if (StringUtils.isNotBlank(accessToken)) { + return accessToken; + } + JSONObject body = new JSONObject(); + body.set("grant_type", "client_credential"); + body.set("appid", appid); + body.set("secret", appSecret); + String response = HttpUtil.post("https://api.weixin.qq.com/cgi-bin/stable_token", body.toString()); + JSONObject json = JSONUtil.parseObj(response); + if (json.getInt("errcode", 0) != 0) { + log.warn("微信 access_token 获取失败: {}", response); + throw new ServiceException("微信凭证获取失败: " + json.getStr("errmsg")); + } + accessToken = json.getStr("access_token"); + RedisUtils.setCacheObject(cacheKey, accessToken, + Duration.ofSeconds(json.getInt("expires_in", 7200) - 300)); + return accessToken; + } + + /** + * 参数 sys.account.xcxApps 的 JSON 元素结构 + */ + @Data + public static class XcxApp { + private String appid; + private String appSecret; + } + } diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/dubbo/RemoteUserServiceImpl.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/dubbo/RemoteUserServiceImpl.java index 08931639f..aebe7a740 100644 --- a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/dubbo/RemoteUserServiceImpl.java +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/dubbo/RemoteUserServiceImpl.java @@ -4,10 +4,14 @@ import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.lang.Opt; import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.RandomUtil; +import cn.hutool.crypto.digest.BCrypt; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import lombok.RequiredArgsConstructor; import org.apache.dubbo.config.annotation.DubboService; import org.dromara.common.core.constant.SystemConstants; import org.dromara.common.core.enums.UserStatus; +import org.dromara.common.core.enums.UserType; import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.user.UserException; import org.dromara.common.core.utils.MapstructUtils; @@ -23,6 +27,7 @@ import org.dromara.system.api.model.PostDTO; import org.dromara.system.api.model.RoleDTO; import org.dromara.system.api.model.XcxLoginUser; import org.dromara.system.domain.SysUser; +import org.dromara.system.domain.SysSocial; import org.dromara.system.domain.SysUserPost; import org.dromara.system.domain.SysUserRole; import org.dromara.system.domain.bo.SysUserBo; @@ -33,8 +38,10 @@ import org.dromara.system.domain.vo.SysUserVo; import org.dromara.system.mapper.SysUserMapper; import org.dromara.system.mapper.SysUserPostMapper; import org.dromara.system.mapper.SysUserRoleMapper; +import org.dromara.system.mapper.SysSocialMapper; import org.dromara.system.service.*; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.*; @@ -58,6 +65,7 @@ public class RemoteUserServiceImpl implements RemoteUserService { private final SysUserMapper userMapper; private final SysUserRoleMapper userRoleMapper; private final SysUserPostMapper userPostMapper; + private final SysSocialMapper socialMapper; /** * 通过用户名查询用户信息 @@ -138,23 +146,34 @@ public class RemoteUserServiceImpl implements RemoteUserService { } /** - * 通过openid查询用户信息 + * 通过 openid 查询小程序用户,首次登录自动以授权手机号注册并绑定 openid * * @param openid openid + * @param phone 手机号一键登录获取的真实手机号,首次登录自动注册时作为账号,可为空 * @return 结果 */ @Override - public XcxLoginUser getUserInfoByOpenid(String openid) throws UserException { - // todo 自行实现 userService.selectUserByOpenid(openid); - SysUser sysUser = new SysUser(); + @Transactional(rollbackFor = Exception.class) + public XcxLoginUser getUserInfoByOpenid(String openid, String phone) throws UserException { + // 通过 sys_social 绑定关系查询小程序用户(source 固定为 xcx) + SysSocial social = socialMapper.selectOne(new LambdaQueryWrapper() + .eq(SysSocial::getSource, "xcx") + .eq(SysSocial::getOpenId, openid) + .last("limit 1")); + SysUser sysUser; + if (ObjectUtil.isNull(social)) { + // 首次登录,以授权手机号自动注册 + sysUser = registerXcxUser(openid, phone); + } else { + sysUser = userMapper.selectById(social.getUserId()); + } if (ObjectUtil.isNull(sysUser)) { - // todo 用户不存在 业务逻辑自行实现 + throw new UserException("user.not.exists"); } if (UserStatus.DISABLE.getCode().equals(sysUser.getStatus())) { - // todo 用户已被停用 业务逻辑自行实现 + throw new UserException("user.blocked", sysUser.getUserName()); } // 框架登录不限制从什么表查询 只要最终构建出 LoginUser 即可 - // 此处可根据登录用户的数据不同 自行创建 loginUser 属性不够用继承扩展就行了 XcxLoginUser loginUser = new XcxLoginUser(); loginUser.setUserId(sysUser.getUserId()); loginUser.setUsername(sysUser.getUserName()); @@ -164,6 +183,48 @@ public class RemoteUserServiceImpl implements RemoteUserService { return loginUser; } + /** + * 小程序首次登录自动注册:以微信手机号一键登录授权的真实手机号作为账号,昵称默认"微信用户+随机数", + * 密码取 sys.user.initPassword 参数(默认 123456),头像留空由前端展示默认头像,性别默认未知。 + * 若已存在同手机号账号(如之前用手机号注册过的用户),直接绑定该账号。 + */ + private SysUser registerXcxUser(String openid, String phone) { + if (StringUtils.isBlank(phone)) { + throw new ServiceException("首次登录需要手机号授权,请重新点击手机号授权后登录"); + } + if (!phone.matches("^1[3-9]\\d{9}$")) { + throw new ServiceException("手机号格式不正确: " + phone); + } + SysUser sysUser = userMapper.selectOne(new LambdaQueryWrapper() + .eq(SysUser::getUserName, phone) + .last("limit 1")); + if (ObjectUtil.isNull(sysUser)) { + String initPassword = StringUtils.blankToDefault( + configService.selectConfigByKey("sys.user.initPassword"), "123456"); + SysUserBo userBo = new SysUserBo(); + userBo.setUserName(phone); + userBo.setNickName("微信用户" + RandomUtil.randomNumbers(4)); + userBo.setPassword(BCrypt.hashpw(initPassword)); + userBo.setGender("2"); + userBo.setUserType(UserType.APP_USER.getUserType()); + userBo.setStatus(UserStatus.OK.getCode()); + userBo.setRemark("小程序首次登录自动注册"); + sysUser = MapstructUtils.convert(userBo, SysUser.class); + userMapper.insert(sysUser); + } + + // 绑定 openid 与用户关系 + SysSocial social = new SysSocial(); + social.setUserId(sysUser.getUserId()); + social.setAuthId(openid); + social.setSource("xcx"); + social.setOpenId(openid); + social.setUserName(sysUser.getUserName()); + social.setNickName(sysUser.getNickName()); + socialMapper.insert(social); + return sysUser; + } + /** * 注册用户信息 * diff --git a/script/config/nacos/ruoyi-auth.yml b/script/config/nacos/ruoyi-auth.yml index caec6cf9d..dda6df763 100644 --- a/script/config/nacos/ruoyi-auth.yml +++ b/script/config/nacos/ruoyi-auth.yml @@ -10,6 +10,11 @@ security: numberLength: 1 # 字符验证码长度 charLength: 4 + # 小程序登录配置(grantType=xcx) + # 优先使用参数管理中的 sys.account.xcxApps(JSON 数组,支持多个小程序);未配置该参数时回退使用此处的 yml 映射 + xcx: + apps: + # 你的小程序appid: 你的小程序appSecret # 用户配置 user: