5 Commits

Author SHA1 Message Date
KilLze
a8c9e694ba 加一堆注释 2026-01-21 22:45:37 +08:00
KilLze
c4c8ccff4e 优化一下 2026-01-21 19:07:07 +08:00
KilLze
ab63329f2f 加点注释2 2026-01-21 18:45:59 +08:00
KilLze
1028c0773f 加点注释 2026-01-21 18:28:47 +08:00
KilLze
08c6481c51 多端登录功能实现 2026-01-20 17:07:51 +08:00
10 changed files with 167 additions and 63 deletions

41
pom.xml
View File

@@ -7,41 +7,49 @@
<version>0.0.1-SNAPSHOT</version>
<name>dating</name>
<description>dating</description>
<properties>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<spring-boot.version>2.6.13</spring-boot.version>
</properties>
<!-- 核心依赖Spring Boot基础、数据库访问、Web功能等 -->
<dependencies>
<!-- Spring Boot 核心启动器 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- MyBatis 持久层框架 -->
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>3.5.10</version>
</dependency>
<!-- Redis 缓存支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Spring Boot Web 开发支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MySQL 数据库连接器 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Lombok 工具,用于简化实体类开发 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
@@ -49,26 +57,28 @@
<optional>true</optional>
</dependency>
<!-- Added MyBatis Spring Boot Starter dependency -->
<!-- MyBatis Spring Boot 启动器 -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.0</version>
</dependency>
<!-- Spring Boot 测试支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Mockito 测试工具 -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-inline</artifactId>
<scope>test</scope>
</dependency>
<!-- JUnit Platform Launcher for resolving junit-platform-launcher:1.8.2 issue -->
<!-- JUnit 5 测试平台启动器 -->
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-launcher</artifactId>
@@ -76,71 +86,79 @@
<scope>test</scope>
</dependency>
<!-- Apache Commons Lang3 工具包 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.12.0</version>
</dependency>
<!-- OkHttp用于调用API -->
<!-- OkHttp HTTP 客户端 -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<!-- AOP起步依赖 -->
<!-- AOP(面向切面编程)起步依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- WebSocket 起步依赖 -->
<!-- WebSocket 实时通信起步依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- 阿里云相关依赖 -->
<!-- 阿里云服务相关依赖 -->
<!-- 阿里云对象存储服务(OSS) SDK -->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.17.4</version>
</dependency>
<!-- 阿里云内容安全服务 -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>green20220302</artifactId>
<version>3.0.1</version>
</dependency>
<!-- FastJSON JSON解析库 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.83</version>
</dependency>
<!-- 阿里云Java SDK核心库 -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
<version>4.6.3</version>
</dependency>
<!-- 阿里云绿色服务SDK -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-green</artifactId>
<version>3.4.2</version>
</dependency>
<!-- 阿里云图像审核服务 -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>imageaudit20191230</artifactId>
<version>2.0.6</version>
</dependency>
<!-- Tea OpenAPI规范实现 -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>tea-openapi</artifactId>
<version>0.2.8</version>
</dependency>
<!-- JWT 相关依赖 -->
<!-- JWT 认证授权相关依赖 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
@@ -166,18 +184,19 @@
<version>3.0.0</version>
</dependency>
<!-- Spring Mail 邮件发送 -->
<!-- Spring Mail 邮件发送功能 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- Redis 依赖 -->
<!-- Redis 数据缓存依赖(重复定义,可删除) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- PageHelper 分页插件 -->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
@@ -185,8 +204,10 @@
</dependency>
</dependencies>
<!-- 依赖管理:统一管理项目中使用的依赖版本 -->
<dependencyManagement>
<dependencies>
<!-- Spring Boot 依赖管理 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>

View File

@@ -14,19 +14,21 @@ import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) {
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
// 创建RedisTemplate对象
RedisTemplate redisTemplate = new RedisTemplate<>();
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
// 设置redis的连接工厂对象
redisTemplate.setConnectionFactory(redisConnectionFactory);
// 设置redis key的序列化
redisTemplate.setKeySerializer(new StringRedisSerializer());
// 设置value的序列化器
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
// 设置hash类型的key和value的序列化器
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
// Key和HashKey都使用String序列化
StringRedisSerializer stringSerializer = new StringRedisSerializer();
redisTemplate.setKeySerializer(stringSerializer);
redisTemplate.setHashKeySerializer(stringSerializer);
// Value和HashValue使用JSON序列化
GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer();
redisTemplate.setValueSerializer(jsonSerializer);
redisTemplate.setHashValueSerializer(jsonSerializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;

View File

@@ -6,7 +6,15 @@ package com.bao.dating.context;
*/
public class UserContext {
/**
* 当前线程的用户ID
*/
private static final ThreadLocal<Long> USER_HOLDER = new ThreadLocal<>();
/**
* 当前线程的设备ID
*/
private static final ThreadLocal<String> DEVICE_HOLDER = new ThreadLocal<>();
/**
* 设置当前线程的用户ID
@@ -24,12 +32,27 @@ public class UserContext {
return USER_HOLDER.get();
}
/**
* 设置当前线程的设备ID
* @param deviceId 设备ID
*/
public static void setDeviceId(String deviceId) {
DEVICE_HOLDER.set(deviceId);
}
/**
* 获取当前线程的设备ID
* @return 当前设备ID如果未设置则返回null
*/
public static String getDeviceId() {
return DEVICE_HOLDER.get();
}
/**
* 清除当前线程的用户ID和token
*/
public static void clear() {
USER_HOLDER.remove();
DEVICE_HOLDER.remove();
}
}

View File

@@ -48,7 +48,8 @@ public class UserController {
@PostMapping("/logout")
public Result<Void> logout(HttpServletRequest request) {
String token = request.getHeader("token");
userService.logout(token);
String deviceId = request.getHeader("deviceId");
userService.logout(token, deviceId);
return Result.success(ResultCode.SUCCESS,"退出登录成功",null);
}

View File

@@ -7,12 +7,15 @@ import com.bao.dating.context.UserContext;
import com.bao.dating.util.JwtUtil;
import io.jsonwebtoken.Claims;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import java.io.IOException;
/**
* HttpToken拦截器类
* 用于拦截请求并验证JWT token的有效性同时从token中解析用户信息
@@ -25,6 +28,7 @@ public class TokenInterceptor implements HandlerInterceptor {
@Autowired
private RedisTemplate redisTemplate;
/**
* 在请求处理之前进行拦截
* 从请求头或URL参数中获取token验证其有效性并将用户ID保存到ThreadLocal中
@@ -42,28 +46,35 @@ public class TokenInterceptor implements HandlerInterceptor {
//当前拦截到的不是动态方法,直接放行
return true;
}
// 从 header 获取 token
String token = request.getHeader("token");
if (StringUtils.isBlank(token)) {
write401(response, "未登录,请先登录");
return false;
}
// 获取 deviceId多设备关键
String deviceId = request.getHeader("deviceId");
if (StringUtils.isBlank(deviceId)) {
write401(response, "设备标识缺失");
return false;
}
try {
log.info("jwt校验: {}", token);
log.info("HTTP鉴权 token={}, deviceId={}", token, deviceId);
// 验证 token 是否有效(包括是否过期)
if (!JwtUtil.validateToken(token)) {
log.error("Token无效或已过期");
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("Token无效或已过期");
write401(response, "Token无效或已过期");
return false;
}
// 检查 token 是否在黑名单中
Object blacklistToken = redisTemplate.opsForValue().get("jwt:blacklist:" + token);
if (blacklistToken != null) {
log.error("Token已在黑名单中");
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("登录已失效, 请重新登录");
write401(response, "登录已失效,请重新登录");
return false;
}
@@ -74,7 +85,6 @@ public class TokenInterceptor implements HandlerInterceptor {
String banKey = "user:ban:" + userId;
if (Boolean.TRUE.equals(redisTemplate.hasKey(banKey))) {
String reason = String.valueOf(redisTemplate.opsForValue().get(banKey));
log.error("用户 {} 已被封禁,原因:{}", userId, reason);
response.setStatus(403);
response.setContentType("application/json;charset=UTF-8");
@@ -82,24 +92,19 @@ public class TokenInterceptor implements HandlerInterceptor {
return false;
}
// 从Redis获取存储的token进行比对
Object redisTokenObj = redisTemplate.opsForValue().get("login:token:" + userId);
String redisToken = redisTokenObj != null ? redisTokenObj.toString() : null;
// 多设备 token 校验
String redisTokenKey = "login:token:" + userId + ":" + deviceId;
Object redisTokenObj = redisTemplate.opsForValue().get(redisTokenKey);
// 验证Redis中的token是否存在且匹配
if (redisToken == null || !redisToken.equals(token)) {
log.error("登录已失效");
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("登录已失效");
if (redisTokenObj == null || !token.equals(redisTokenObj.toString())) {
write401(response, "登录状态已失效");
return false;
}
log.info("用户: {}", userId);
// 保存 userId 到 ThreadLocal
// 保存 登录信息 到 ThreadLocal
UserContext.setUserId(userId);
// 保存 token 到 ThreadLocal
UserContext.setToken(token);
UserContext.setDeviceId(deviceId);
log.info("token验证成功 userId={}, deviceId={}", userId, deviceId);
return true;
} catch (Exception e) {
log.error("Token 校验失败: {}", e.getMessage());
@@ -122,4 +127,16 @@ public class TokenInterceptor implements HandlerInterceptor {
UserContext.clear();
}
/**
* 响应错误信息
* @param response
* @param msg
* @throws IOException
*/
private void write401(HttpServletResponse response, String msg) throws IOException {
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(msg);
}
}

View File

@@ -42,9 +42,10 @@ public class WsAuthInterceptor implements HandshakeInterceptor {
// 从URL参数中获取token
String token = servletRequest.getParameter("token");
String deviceId = servletRequest.getParameter("deviceId");
if (StringUtils.isBlank(token)) {
log.error("WebSocket握手失败:令牌丢");
if (StringUtils.isBlank(token) || StringUtils.isBlank(deviceId)) {
log.error("WebSocket认证失败:token或deviceId缺");
return false;
}
@@ -81,21 +82,19 @@ public class WsAuthInterceptor implements HandshakeInterceptor {
return false;
}
// 从Redis获取存储的token进行比对
String redisTokenKey = "login:token:" + userId;
// 多设备 token 校验
String redisTokenKey = "login:token:" + userId + ":" + deviceId;
Object redisTokenObj = redisTemplate.opsForValue().get(redisTokenKey);
String redisToken = redisTokenObj != null ? redisTokenObj.toString() : null;
log.info("Redis中存储的token: {}", redisToken != null ? "存在" : "不存在");
// 验证Redis中的token是否存在且匹配
if (redisToken == null || !redisToken.equals(token)) {
log.error("登录已失效 - Redis中token不存在或不匹配");
if (redisTokenObj == null || !token.equals(redisTokenObj.toString())) {
log.error("登录已失效");
return false;
}
log.info("WebSocket认证成功用户ID: {}", userId);
// 将用户ID保存到attributes中
// 将信息保存到attributes中
attributes.put("userId", userId);
attributes.put("deviceId", deviceId);
log.info("WebSocket认证成功 userId={}, deviceId={}", userId, deviceId);
return true;
}
catch (NumberFormatException e) {

View File

@@ -12,4 +12,10 @@ import java.io.Serializable;
public class UserLoginDTO implements Serializable {
private String username;
private String password;
/** 设备唯一标识(前端生成)*/
private String deviceId;
/** 设备类型 */
private String deviceType;
/** 设备名称 */
private String deviceName;
}

View File

@@ -12,4 +12,5 @@ public class UserLoginVO implements Serializable {
private Long userId;
private String nickname;
private String token;
private String deviceId;
}

View File

@@ -25,7 +25,7 @@ public interface UserService {
* @param token 登录凭证
* @return 注册结果
*/
void logout(String token);
void logout(String token, String deviceId);
/**
* 查询个人信息

View File

@@ -82,6 +82,9 @@ public class UserServiceImpl implements UserService {
if (userLoginDTO == null || userLoginDTO.getUsername() == null || userLoginDTO.getPassword() == null) {
throw new RuntimeException("用户名或密码不能为空");
}
if (userLoginDTO.getDeviceId() == null || userLoginDTO.getDeviceName() == null || userLoginDTO.getDeviceType() == null){
throw new RuntimeException("未获取到设备");
}
// 查询用户
User user = userMapper.getByUsername(userLoginDTO.getUsername());
if (user == null) {
@@ -101,19 +104,40 @@ public class UserServiceImpl implements UserService {
// 生成token
String token = JwtUtil.generateToken(String.valueOf(user.getUserId()));
String redisKey = "login:token:" + user.getUserId();
Long userId = user.getUserId();
String deviceId = userLoginDTO.getDeviceId();
// 缓存登录token
String tokenKey = "login:token:" + userId+ ":" + deviceId;
redisTemplate.opsForValue().set(
redisKey,
tokenKey,
token,
7,
TimeUnit.DAYS
);
// 设备信息 Hash
String deviceKey = "user:device:" + userId+ ":" + deviceId;
Map<String, Object> deviceInfo = new HashMap<>();
deviceInfo.put("token", token);
deviceInfo.put("deviceType", userLoginDTO.getDeviceType());
deviceInfo.put("deviceName", userLoginDTO.getDeviceName());
deviceInfo.put("loginTime", System.currentTimeMillis());
// 存储设备信息
redisTemplate.opsForHash().putAll(deviceKey, deviceInfo);
redisTemplate.expire(deviceKey, 7, TimeUnit.DAYS);
// 缓存用户设备信息
String deviceSetKey = "user:devices:" + userId;
redisTemplate.opsForSet().add(deviceSetKey, deviceId);
// 封装返回
UserLoginVO userLoginVO = new UserLoginVO();
userLoginVO.setUserId(user.getUserId());
userLoginVO.setUserId(userId);
userLoginVO.setNickname(user.getNickname());
userLoginVO.setToken(token);
userLoginVO.setDeviceId(deviceId);
return userLoginVO;
}
@@ -122,10 +146,10 @@ public class UserServiceImpl implements UserService {
* @param token 登录凭证
*/
@Override
public void logout(String token) {
public void logout(String token, String deviceId) {
Claims claims = JwtUtil.getClaimsFromToken(token);
// 获取token信息
String subject = claims.getSubject();
String userId = claims.getSubject();
// 获取token的过期时间
Date expiration = claims.getExpiration();
// 判断 token 是否已过期
@@ -135,10 +159,15 @@ public class UserServiceImpl implements UserService {
return;
}
// 从Redis中删除登录token记录
String loginTokenKey = "login:token:" + subject;
// 从Redis中删除当前设备登录token记录
String loginTokenKey = "login:token:" + userId + ":" + deviceId;
redisTemplate.delete(loginTokenKey);
// 删除设备信息
String deviceKey = "user:device:" + userId + ":" + deviceId;
redisTemplate.delete(deviceKey);
// 将token加入黑名单
String logoutKey = "jwt:blacklist:" + token;
redisTemplate.opsForValue().set(
logoutKey,
@@ -499,6 +528,11 @@ public class UserServiceImpl implements UserService {
return result;
}
/**
* 判断用户是否在线
* @param userId 用户ID
* @return true: 在线false: 离线
*/
@Override
public boolean isUserOnline(Long userId) {
if (userId == null) {