diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/vo/WechatAuthVO.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/vo/WechatAuthVO.java new file mode 100644 index 0000000..5f9b594 --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/vo/WechatAuthVO.java @@ -0,0 +1,10 @@ +package com.ruoyi.cms.domain.vo; + +import lombok.Data; + +@Data +public class WechatAuthVO { + private String openid; + private String unionid; + private String sessionKey; +} diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/mapper/AppUserMapper.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/mapper/AppUserMapper.java index fa69bb7..ec2eb56 100644 --- a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/mapper/AppUserMapper.java +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/mapper/AppUserMapper.java @@ -8,6 +8,7 @@ import com.ruoyi.common.core.domain.entity.AppUserShow; import com.ruoyi.common.core.domain.entity.MyChart; import com.ruoyi.common.core.domain.entity.AppUser; import com.ruoyi.common.core.domain.entity.SysUser; +import org.apache.ibatis.annotations.Param; /** * APP用户Mapper接口 @@ -26,15 +27,15 @@ public interface AppUserMapper extends BaseMapper List selectByJobId(Long jobId); - AppUser selectByOpenid(String openid); + AppUser selectByOpenid(@Param("openid")String openid, @Param("userType") String userType); int insertSysUserRole(Map map); int insertSysUser(SysUser sysUser); - MyChart getMyTj(Long userId); + MyChart getMyTj(@Param("userId") Long userId); - SysUser selectSysUserIdcard(String idCard); + SysUser selectSysUserIdcard(@Param("idCard") String idCard); List selectUserApplyList(AppUser appUser); } diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/IAppUserService.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/IAppUserService.java index 403cd4b..52a400c 100644 --- a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/IAppUserService.java +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/IAppUserService.java @@ -58,7 +58,11 @@ public interface IAppUserService public AppUser getPhone(String phone); - AppUser selectByOpenid(String openid); + public AppUser getPhoneAndNoRole(String phone); + + public AppUser getPhoneAndUserType(String phone,String userType); + + AppUser selectByOpenid(String openid,String userType); public AppUser registerAppUser(RegisterBody registerBody); diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/impl/AppUserServiceImpl.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/impl/AppUserServiceImpl.java index ed871f6..916c313 100644 --- a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/impl/AppUserServiceImpl.java +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/impl/AppUserServiceImpl.java @@ -185,8 +185,20 @@ public class AppUserServiceImpl extends ServiceImpl imple } @Override - public AppUser selectByOpenid(String openid) { - return appUserMapper.selectByOpenid(openid); + public AppUser getPhoneAndNoRole(String phone) { + return appUserMapper.selectOne(new LambdaQueryWrapper() + .eq(AppUser::getPhone, phone).eq(AppUser::getDelFlag,"0").isNull(AppUser::getIsCompanyUser).orderByDesc(AppUser::getUpdateTime).last("LIMIT 1")); + } + + @Override + public AppUser getPhoneAndUserType(String phone,String userType) { + return appUserMapper.selectOne(new LambdaQueryWrapper() + .eq(AppUser::getPhone, phone).eq(AppUser::getIsCompanyUser,userType).eq(AppUser::getDelFlag,"0").orderByDesc(AppUser::getUpdateTime).last("LIMIT 1")); + } + + @Override + public AppUser selectByOpenid(String openid,String userType) { + return appUserMapper.selectByOpenid(openid,userType); } @Override diff --git a/ruoyi-bussiness/src/main/resources/mapper/app/AppUserMapper.xml b/ruoyi-bussiness/src/main/resources/mapper/app/AppUserMapper.xml index f009314..e19a0ce 100644 --- a/ruoyi-bussiness/src/main/resources/mapper/app/AppUserMapper.xml +++ b/ruoyi-bussiness/src/main/resources/mapper/app/AppUserMapper.xml @@ -68,7 +68,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/core/redis/DistributedLockUtil.java b/ruoyi-common/src/main/java/com/ruoyi/common/core/redis/DistributedLockUtil.java new file mode 100644 index 0000000..e91d647 --- /dev/null +++ b/ruoyi-common/src/main/java/com/ruoyi/common/core/redis/DistributedLockUtil.java @@ -0,0 +1,211 @@ +package com.ruoyi.common.core.redis; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +/** + * JDK 1.8 兼容版分布式锁工具类(基于 Redis) + */ +@Component +public class DistributedLockUtil { + @Autowired + private RedisCache redisCache; + + // 锁默认配置 + private static final long DEFAULT_EXPIRE_SECONDS = 30; + private static final long DEFAULT_ACQUIRE_TIMEOUT_SECONDS = 5; + private static final long RENEW_INTERVAL_SECONDS = DEFAULT_EXPIRE_SECONDS / 3; + + // 续期线程池 + private final ScheduledExecutorService renewExecutor = Executors.newScheduledThreadPool( + Runtime.getRuntime().availableProcessors(), + new ThreadFactory() { + private final AtomicBoolean init = new AtomicBoolean(false); + + @Override + public Thread newThread(Runnable r) { + Thread thread = new Thread(r, "distributed-lock-renewer"); + thread.setDaemon(true); + if (init.compareAndSet(false, true)) { + Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() { + @Override + public void run() { + renewExecutor.shutdown(); + } + })); + } + return thread; + } + } + ); + + // 释放锁 Lua 脚本(原子操作) + private static final String RELEASE_LOCK_LUA_SCRIPT = + "if redis.call('get', KEYS[1]) == ARGV[1] then " + + " return redis.call('del', KEYS[1]) " + + "else " + + " return 0 " + + "end"; + private final DefaultRedisScript releaseScript = new DefaultRedisScript<>(); + + // 初始化 Lua 脚本 + { + releaseScript.setScriptText(RELEASE_LOCK_LUA_SCRIPT); + releaseScript.setResultType(Long.class); + } + + /** + * 获取锁(带自动续期) + */ + public String acquireLockWithRenewal(String lockKey) { + return acquireLockWithRenewal(lockKey, DEFAULT_EXPIRE_SECONDS, DEFAULT_ACQUIRE_TIMEOUT_SECONDS); + } + + /** + * 自定义参数获取锁 + */ + public String acquireLockWithRenewal(String lockKey, long expireSeconds, long acquireTimeoutSeconds) { + String identifier = UUID.randomUUID().toString(); + long endTime = System.currentTimeMillis() + acquireTimeoutSeconds * 1000; + + while (System.currentTimeMillis() < endTime) { + boolean locked = tryLockOnce(lockKey, identifier, expireSeconds); + if (locked) { + startRenewal(lockKey, identifier, expireSeconds); + return identifier; + } + + // 指数退避重试 + try { + long sleepMs = calculateBackoffSleep(System.currentTimeMillis() - (endTime - acquireTimeoutSeconds * 1000)); + Thread.sleep(sleepMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return null; + } + } + return null; + } + + /** + * 原子释放锁 + */ + public boolean releaseLockSafely(String lockKey, String identifier) { + if (identifier == null) { + return false; + } + Long result = (Long) redisCache.redisTemplate.execute( + releaseScript, + Collections.singletonList(lockKey), + identifier + ); + return result != null && result > 0; + } + + /** + * 尝试获取一次锁 + */ + private boolean tryLockOnce(String lockKey, String identifier, long expireSeconds) { + try { + if (!redisCache.hasKey(lockKey)) { + redisCache.setCacheObject(lockKey, identifier, (int) expireSeconds, TimeUnit.SECONDS); + String storedId = redisCache.getCacheObject(lockKey); + return identifier.equals(storedId); + } + return false; + } catch (Exception e) { + return false; + } + } + + /** + * 启动锁自动续期 + */ + private void startRenewal(final String lockKey, final String identifier, final long expireSeconds) { + final AtomicReference> futureRef = new AtomicReference<>(); + Runnable renewalTask = new Runnable() { + @Override + public void run() { + try { + String storedId = redisCache.getCacheObject(lockKey); + if (identifier.equals(storedId)) { + redisCache.expire(lockKey, expireSeconds); + } else { + cancelFuture(); + } + } catch (Exception e) { + cancelFuture(); + } + } + + private void cancelFuture() { + ScheduledFuture future = futureRef.get(); + if (future != null && !future.isCancelled()) { + future.cancel(false); + } + } + }; + + ScheduledFuture future = renewExecutor.scheduleAtFixedRate( + renewalTask, + RENEW_INTERVAL_SECONDS, + RENEW_INTERVAL_SECONDS, + TimeUnit.SECONDS + ); + futureRef.set(future); + } + + /** + * 指数退避算法(计算重试间隔) + */ + private long calculateBackoffSleep(long elapsedMs) { + int retryCount = (int) (elapsedMs / 100); + long sleepMs = 100L * (1 << Math.min(retryCount, 10)); + return Math.min(sleepMs, 1000); + } + + /** + * 自动释放锁工具(支持 try-with-resources) + */ + public static class AutoReleaseLock implements AutoCloseable { + private final DistributedLockUtil lockUtil; + private final String lockKey; + private final String identifier; + + public AutoReleaseLock(DistributedLockUtil lockUtil, String lockKey, String identifier) { + this.lockUtil = lockUtil; + this.lockKey = lockKey; + this.identifier = identifier; + } + + public boolean isLocked() { + return identifier != null; + } + + @Override + public void close() { + if (isLocked()) { + lockUtil.releaseLockSafely(lockKey, identifier); + } + } + } + + /** + * 简化锁使用 + */ + public AutoReleaseLock tryLock(String lockKey) { + String identifier = acquireLockWithRenewal(lockKey); + return new AutoReleaseLock(this, lockKey, identifier); + } +} \ No newline at end of file diff --git a/ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/SysLoginService.java b/ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/SysLoginService.java index 391829f..4cc11a9 100644 --- a/ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/SysLoginService.java +++ b/ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/SysLoginService.java @@ -4,6 +4,7 @@ import javax.annotation.Resource; import com.alibaba.fastjson.JSONObject; import com.alibaba.fastjson2.JSON; +import com.ruoyi.cms.domain.vo.WechatAuthVO; import com.ruoyi.cms.service.IAppUserService; import com.ruoyi.cms.util.StringUtil; import com.ruoyi.cms.util.WechatUtil; @@ -12,6 +13,7 @@ import com.ruoyi.common.core.domain.entity.AppUser; import com.ruoyi.common.core.domain.model.LoginBody; import com.ruoyi.common.core.domain.model.LoginSiteUser; import com.ruoyi.common.core.domain.model.RegisterBody; +import com.ruoyi.common.core.redis.DistributedLockUtil; import com.ruoyi.common.utils.*; import com.ruoyi.framework.web.exception.ParamErrorConstants; import org.springframework.beans.factory.annotation.Autowired; @@ -69,6 +71,9 @@ public class SysLoginService WechatUtil wechatUtil; @Autowired private IAppUserService appUserService; + @Autowired + private DistributedLockUtil distributedLockUtil; + /** * 登录验证 * @@ -258,7 +263,7 @@ public class SysLoginService return AjaxResult.error("微信授权失败"); } //验证是否登录过 - AppUser existingUser=appUserService.selectByOpenid(openid); + AppUser existingUser=appUserService.selectByOpenid(openid,dto.getUserType()); if(existingUser!=null){ if(StringUtils.isEmpty(existingUser.getIsCompanyUser())){ updateAppUserCommon(existingUser,openid,unionid,dto.getUserType()); @@ -316,44 +321,37 @@ public class SysLoginService * 小程序登录主逻辑 */ public AjaxResult appLoginNew(LoginBody dto) { - AjaxResult validateResult = validateLoginParam(dto, false); + //1.验证基础参数 + AjaxResult validateResult = validateBaseParam(dto); if (validateResult != null) { return validateResult; } try { - JSONObject sessionInfo = wechatUtil.code2Session(dto.getCode()); - String openid = sessionInfo.getString("openid"); - String unionid = sessionInfo.getString("unionid"); - String sessionKey = sessionInfo.getString("session_key"); - - if (openid == null) { + //2. 微信授权获取OpenID/UnionID/SessionKey + WechatAuthVO wechatAuthVO = getWechatAuthInfo(dto.getCode()); + if (wechatAuthVO == null) { return AjaxResult.error("微信授权失败"); } + String openid = wechatAuthVO.getOpenid(); + String unionid = wechatAuthVO.getUnionid(); + String sessionKey = wechatAuthVO.getSessionKey(); - AppUser existingUser = appUserService.selectByOpenid(openid); + String userType = dto.getUserType(); + //3. 优先匹配「OpenID+角色」的老用户 + AppUser existingUser = appUserService.selectByOpenid(openid,userType); if (existingUser != null) { - return handleExistingUser(existingUser, dto.getUserType()); + return handleExistingUser(existingUser, userType); } - validateResult = validateLoginParam(dto, true); - if (validateResult != null) { - return validateResult; - } - - JSONObject phoneInfo = wechatUtil.decryptPhoneNumber(dto.getEncryptedData(), sessionKey, dto.getIv()); - String phone = phoneInfo.getString("phoneNumber"); - + // 4. 解密获取手机号(含二次校验) + String phone = decryptPhone(dto, sessionKey); if (phone == null) { return AjaxResult.error("获取手机号失败"); } - AppUser phoneUser = appUserService.getPhone(phone); - if (phoneUser != null) { - return handlePhoneBoundUser(phoneUser, openid, unionid, dto.getUserType()); - } else { - return handleNewUser(openid, unionid, phone, dto.getUserType()); - } + // 5. 处理用户匹配与注册(核心逻辑拆分到独立方法) + return handleUserMatchAndRegister(openid, unionid, phone, userType); } catch (Exception e) { System.err.println("登录失败:" + e.getMessage()); return AjaxResult.error("登录失败,请稍后重试"); @@ -361,9 +359,11 @@ public class SysLoginService } /** - * 参数校验方法(仅返回错误信息,补充userType校验) + * 1-基础参数校验(原validateLoginParam false场景) + * @param dto + * @return */ - private AjaxResult validateLoginParam(LoginBody dto, boolean needDecryptPhone) { + private AjaxResult validateBaseParam(LoginBody dto) { if (dto == null) { return AjaxResult.error(ParamErrorConstants.PARAM_NULL_MSG); } @@ -376,17 +376,134 @@ public class SysLoginService !StringUtil.IS_JOB_REQUEST_USER.equals(userType)) { return AjaxResult.error(ParamErrorConstants.USER_TYPE_INVALID_MSG); } - if (needDecryptPhone) { - if (StringUtils.isEmpty(dto.getEncryptedData())) { - return AjaxResult.error(ParamErrorConstants.ENCRYPTED_DATA_EMPTY_MSG); - } - if (StringUtils.isEmpty(dto.getIv())) { - return AjaxResult.error(ParamErrorConstants.IV_EMPTY_MSG); - } + return null; + } + + /** + * 2-微信授权信息获取(封装code2Session逻辑) + * @param code + * @return + */ + private WechatAuthVO getWechatAuthInfo(String code) { + JSONObject sessionInfo = wechatUtil.code2Session(code); + if (sessionInfo == null || sessionInfo.getString("openid") == null) { + return null; + } + WechatAuthVO authVO = new WechatAuthVO(); + authVO.setOpenid(sessionInfo.getString("openid")); + authVO.setUnionid(StringUtils.isBlank(sessionInfo.getString("unionid")) ? "" : sessionInfo.getString("unionid")); + authVO.setSessionKey(sessionInfo.getString("session_key")); + return authVO; + } + + /** + * 3-手机号解密(含二次参数校验) + * @param dto + * @param sessionKey + * @return + */ + private String decryptPhone(LoginBody dto, String sessionKey) { + // 二次校验(解密相关参数) + AjaxResult validateResult = validateDecryptParam(dto); + if (validateResult != null) { + return null; + } + // 解密手机号 + JSONObject phoneInfo = wechatUtil.decryptPhoneNumber(dto.getEncryptedData(), sessionKey, dto.getIv()); + String phone = phoneInfo.getString("phoneNumber"); + if (phone == null || phone.trim().isEmpty()) { + return null; + } + return phone.trim(); + } + + /** + * 4-解密参数校验(原validateLoginParam true场景) + * @param dto + * @return + */ + private AjaxResult validateDecryptParam(LoginBody dto) { + if (StringUtils.isEmpty(dto.getEncryptedData())) { + return AjaxResult.error(ParamErrorConstants.ENCRYPTED_DATA_EMPTY_MSG); + } + if (StringUtils.isEmpty(dto.getIv())) { + return AjaxResult.error(ParamErrorConstants.IV_EMPTY_MSG); } return null; } + /** + * 5-用户匹配与注册(含锁逻辑、无角色数据处理) + * @param openid + * @param unionid + * @param phone + * @param userType + * @return + */ + private AjaxResult handleUserMatchAndRegister(String openid, String unionid, String phone, String userType) { + // 匹配「手机号+角色」的用户 + AppUser phoneRoleUser = appUserService.getPhoneAndUserType(phone, userType); + if (phoneRoleUser != null) { + return handlePhoneBoundUser(phoneRoleUser, openid, unionid, userType); + } + + // 匹配无角色历史数据 + AppUser noRoleUser = appUserService.getPhoneAndNoRole(phone); + if (noRoleUser != null) { + return handleNoRoleUserBinding(openid, unionid, phone, userType, noRoleUser); + } + + // 全新用户注册 + return handleNewUserRegistration(openid, unionid, phone, userType); + } + + /** + * 6-无角色用户绑定(含分布式锁) + * @param openid + * @param unionid + * @param phone + * @param userType + * @param noRoleUser + * @return + */ + private AjaxResult handleNoRoleUserBinding(String openid, String unionid, String phone, String userType, AppUser noRoleUser) { + String lockKey = "login_no_role_bind_" + phone + "_" + userType; + try (DistributedLockUtil.AutoReleaseLock lock = distributedLockUtil.tryLock(lockKey)) { + if (!lock.isLocked()) { + return AjaxResult.error("登录请求过于频繁,请稍后重试"); + } + // 双重检查 + AppUser doubleCheck = appUserService.getPhoneAndUserType(phone, userType); + if (doubleCheck != null) { + return handlePhoneBoundUser(doubleCheck, openid, unionid, userType); + } + return handlePhoneBoundUser(noRoleUser, openid, unionid, userType); + } + } + + /** + * 7-新用户注册(含分布式锁) + * @param openid + * @param unionid + * @param phone + * @param userType + * @return + */ + private AjaxResult handleNewUserRegistration(String openid, String unionid, String phone, String userType) { + String createLockKey = "login_create_" + phone + "_" + userType; + try (DistributedLockUtil.AutoReleaseLock lock = distributedLockUtil.tryLock(createLockKey)) { + if (!lock.isLocked()) { + return AjaxResult.error("登录请求过于频繁,请稍后重试"); + } + // 双重检查 + AppUser checkNew = appUserService.getPhoneAndUserType(phone, userType); + if (checkNew != null) { + return handlePhoneBoundUser(checkNew, openid, unionid, userType); + } + return handleNewUser(openid, unionid, phone, userType); + } + } + /** * 处理老用户登录(日志用println) */