feat(auth): 小程序登录支持配置化与手机号一键登录自动注册 / Add configurable xcx login with phone auto-registration

- 小程序 appid/密钥支持参数管理 sys.account.xcxApps(JSON 数组,多小程序)优先,yml security.xcx.apps 兜底
- 登录新增 phoneCode 字段,调用微信 getuserphonenumber 解析真实手机号,stable_token 按小程序缓存至 redis
- 首次登录自动注册:以授权手机号为账号(已有同手机号账号则直接绑定),写入 sys_social 绑定 openid
- 自动注册默认昵称/初始密码(sys.user.initPassword)/头像/性别,用户类型 app_user
This commit is contained in:
2026-08-30 18:07:34 +08:00
parent 4f63d9ab7b
commit 42052f7bf5
6 changed files with 238 additions and 11 deletions
@@ -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;
/**
* 注册用户信息
@@ -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;
}
@@ -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<String, String> apps = new LinkedHashMap<>();
}
@@ -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<String, String> 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<String, String> loadApps() {
String configValue = remoteConfigService.getConfigValue("sys.account.xcxApps");
if (StringUtils.isNotBlank(configValue)) {
try {
Map<String, String> 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<String, String> 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<String, String> 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;
}
}
@@ -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<SysSocial>()
.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<SysUser>()
.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;
}
/**
* 注册用户信息
*
+5
View File
@@ -10,6 +10,11 @@ security:
numberLength: 1
# 字符验证码长度
charLength: 4
# 小程序登录配置(grantType=xcx)
# 优先使用参数管理中的 sys.account.xcxApps(JSON 数组,支持多个小程序);未配置该参数时回退使用此处的 yml 映射
xcx:
apps:
# 你的小程序appid: 你的小程序appSecret
# 用户配置
user: