This commit is contained in:
bao
2026-01-20 14:51:53 +08:00
parent 1f81b6fbc1
commit 6b7f6947db
13 changed files with 1381 additions and 7 deletions

482
GIT_CONVENTION.md Normal file
View File

@@ -0,0 +1,482 @@
# Git 使用规范
## 目录
- [分支命名规范](#分支命名规范)
- [提交信息规范](#提交信息规范)
- [工作流程](#工作流程)
- [代码审查](#代码审查)
- [最佳实践](#最佳实践)
---
## 分支命名规范
### 分支类型
#### 1. 主分支
- **master/main**: 生产环境分支,只接受合并,不允许直接提交
- **develop/dev**: 开发主分支,用于集成所有功能
#### 2. 功能分支 (Feature)
```
feature/功能名称
feature/功能名称-简短描述
```
**示例:**
- `feature/user-login`
- `feature/payment-integration`
- `feature/contacts-friends`
#### 3. 修复分支 (Bugfix)
```
bugfix/问题描述
fix/问题描述
```
**示例:**
- `bugfix/login-error`
- `fix/memory-leak`
#### 4. 热修复分支 (Hotfix)
```
hotfix/问题描述
```
**示例:**
- `hotfix/critical-security-patch`
- `hotfix/payment-bug`
#### 5. 发布分支 (Release)
```
release/版本号
```
**示例:**
- `release/v1.0.0`
- `release/v2.1.0`
### 命名规则
- 使用小写字母
- 多个单词用连字符 `-` 分隔
- 避免使用下划线 `_` 或空格
- 分支名要有意义,能清楚表达分支用途
- 避免使用特殊字符:`~`, `^`, `:`, `?`, `*`, `[`, `\`
---
## 提交信息规范
### 提交信息格式
```
<type>(<scope>): <subject>
<body>
<footer>
```
### Type 类型
| 类型 | 说明 | 示例 |
|------|------|------|
| `feat` | 新功能 | `feat: 添加用户登录功能` |
| `fix` | 修复bug | `fix: 修复登录验证失败问题` |
| `docs` | 文档更新 | `docs: 更新API文档` |
| `style` | 代码格式调整(不影响功能) | `style: 格式化代码` |
| `refactor` | 代码重构 | `refactor: 重构用户服务类` |
| `perf` | 性能优化 | `perf: 优化数据库查询性能` |
| `test` | 测试相关 | `test: 添加用户登录单元测试` |
| `chore` | 构建/工具/依赖更新 | `chore: 更新依赖包版本` |
| `ci` | CI/CD相关 | `ci: 配置GitHub Actions` |
| `build` | 构建系统相关 | `build: 更新Maven配置` |
### Scope 范围(可选)
- 指定修改的模块或文件
- 示例:`feat(user): 添加用户注册功能`
### Subject 主题
- 简短描述不超过50个字符
- 使用中文或英文,保持项目统一
- 首字母小写,结尾不加句号
- 使用祈使语气(如:添加、修复、更新)
### Body 正文(可选)
- 详细描述修改内容
- 说明为什么修改,如何修改
- 每行不超过72个字符
- 用空行与 subject 分隔
### Footer 页脚(可选)
- 关联Issue`Closes #123`
- 破坏性变更:`BREAKING CHANGE: 修改了API接口`
### 提交信息示例
#### 简单提交
```
feat: 添加联系人管理功能
```
#### 带scope的提交
```
feat(user): 添加用户头像上传功能
```
#### 详细提交
```
feat(contact): 添加联系人管理功能
- 实现联系人列表查询
- 添加联系人添加/删除接口
- 集成Redis缓存优化性能
Closes #45
```
#### 修复提交
```
fix: 修复登录验证失败问题
修复了当用户密码包含特殊字符时验证失败的问题
Fixes #67
```
---
## 工作流程
### 1. 创建功能分支
```bash
# 从develop分支创建新分支
git checkout develop
git pull origin develop
git checkout -b feature/your-feature-name
```
### 2. 开发过程
```bash
# 经常提交代码
git add .
git commit -m "feat: 实现XXX功能"
# 定期同步主分支
git checkout develop
git pull origin develop
git checkout feature/your-feature-name
git merge develop
```
### 3. 提交前检查
```bash
# 检查代码状态
git status
# 查看修改内容
git diff
# 查看提交历史
git log --oneline
```
### 4. 推送分支
```bash
# 首次推送
git push -u origin feature/your-feature-name
# 后续推送
git push
```
### 5. 合并到主分支
```bash
# 方式1: 通过Pull Request推荐
# 在Git平台创建PR代码审查后合并
# 方式2: 本地合并(不推荐,除非紧急情况)
git checkout develop
git pull origin develop
git merge feature/your-feature-name
git push origin develop
```
### 6. 清理分支
```bash
# 删除本地分支
git branch -d feature/your-feature-name
# 删除远程分支
git push origin --delete feature/your-feature-name
```
---
## 代码审查
### Pull Request 规范
#### PR标题格式
```
[类型] 简短描述
```
**示例:**
- `[Feature] 添加联系人管理功能`
- `[Fix] 修复登录验证问题`
- `[Refactor] 重构用户服务类`
#### PR描述模板
```markdown
## 变更说明
简要描述本次PR的主要变更内容
## 变更类型
- [ ] 新功能
- [ ] Bug修复
- [ ] 代码重构
- [ ] 文档更新
- [ ] 性能优化
- [ ] 其他
## 测试说明
描述如何测试这些变更
## 相关Issue
关联的Issue编号: #123
## 截图(如适用)
[添加相关截图]
```
### 审查检查清单
#### 提交者
- [ ] 代码符合项目规范
- [ ] 已添加必要的测试
- [ ] 已更新相关文档
- [ ] 提交信息清晰明确
- [ ] 无编译错误和警告
- [ ] 已进行自测
#### 审查者
- [ ] 代码逻辑正确
- [ ] 代码风格一致
- [ ] 无明显的性能问题
- [ ] 无安全隐患
- [ ] 测试覆盖充分
- [ ] 文档更新完整
---
## 最佳实践
### 1. 提交频率
-**推荐**: 频繁提交,每次提交完成一个小功能
-**不推荐**: 大量代码一次性提交
### 2. 提交粒度
-**推荐**: 每次提交只做一件事
-**不推荐**: 一个提交包含多个不相关的修改
### 3. 提交前检查
```bash
# 检查代码格式
# 运行测试
# 检查编译错误
```
### 4. 避免的操作
- ❌ 不要在master/main分支直接提交
- ❌ 不要提交临时文件、日志文件
- ❌ 不要提交敏感信息(密码、密钥等)
- ❌ 不要强制推送主分支 (`git push --force`)
### 5. 使用 .gitignore
确保 `.gitignore` 文件包含:
```
# 编译文件
*.class
*.jar
*.war
# IDE文件
.idea/
.vscode/
*.iml
# 日志文件
*.log
# 依赖目录
node_modules/
target/
# 配置文件(包含敏感信息)
application-local.yml
config.properties
```
### 6. 冲突处理
```bash
# 拉取最新代码
git pull origin develop
# 如果有冲突,解决冲突后
git add .
git commit -m "fix: 解决合并冲突"
```
### 7. 撤销操作
#### 撤销工作区修改
```bash
git checkout -- <file>
```
#### 撤销暂存区修改
```bash
git reset HEAD <file>
```
#### 修改最后一次提交
```bash
git commit --amend -m "新的提交信息"
```
#### 回退到指定提交
```bash
# 软回退(保留修改)
git reset --soft <commit-hash>
# 硬回退(丢弃修改)
git reset --hard <commit-hash>
```
---
## 常用命令速查
### 分支操作
```bash
# 查看所有分支
git branch -a
# 创建分支
git branch <branch-name>
# 切换分支
git checkout <branch-name>
# 创建并切换分支
git checkout -b <branch-name>
# 删除本地分支
git branch -d <branch-name>
# 删除远程分支
git push origin --delete <branch-name>
```
### 提交操作
```bash
# 查看状态
git status
# 添加文件
git add <file>
git add .
# 提交
git commit -m "提交信息"
# 查看提交历史
git log
git log --oneline
git log --graph --oneline --all
```
### 远程操作
```bash
# 查看远程仓库
git remote -v
# 拉取代码
git pull origin <branch-name>
# 推送代码
git push origin <branch-name>
# 获取远程更新(不合并)
git fetch origin
```
### 其他实用命令
```bash
# 查看差异
git diff
git diff <branch1> <branch2>
# 暂存修改
git stash
git stash pop
# 查看文件历史
git log -- <file>
# 查看某行的修改历史
git blame <file>
```
---
## 版本标签规范
### 标签命名
使用语义化版本号:`v主版本号.次版本号.修订号`
**示例:**
- `v1.0.0` - 初始发布
- `v1.1.0` - 新功能
- `v1.1.1` - Bug修复
- `v2.0.0` - 重大更新
### 标签操作
```bash
# 创建标签
git tag -a v1.0.0 -m "版本1.0.0发布"
# 推送标签
git push origin v1.0.0
# 推送所有标签
git push origin --tags
# 查看标签
git tag
git show v1.0.0
```
---
## 注意事项
1. **保护主分支**: master/main 分支应该设置保护规则,禁止直接推送
2. **定期同步**: 开发过程中定期从主分支同步代码,避免冲突积累
3. **及时清理**: 合并后的分支及时删除,保持仓库整洁
4. **提交前测试**: 确保代码可以正常编译和运行
5. **代码审查**: 重要功能必须经过代码审查才能合并
---
## 参考资源
- [Git官方文档](https://git-scm.com/doc)
- [Conventional Commits](https://www.conventionalcommits.org/)
- [Semantic Versioning](https://semver.org/)
---
**最后更新**: 2024年

View File

@@ -1,12 +1,13 @@
package com.bao.dating.context; package com.bao.dating.context;
/** /**
* 用户上下文类用于保存当前线程的用户ID * 用户上下文类用于保存当前线程的用户ID和token
* @author lenovo * @author lenovo
*/ */
public class UserContext { public class UserContext {
private static final ThreadLocal<Long> USER_HOLDER = new ThreadLocal<>(); private static final ThreadLocal<Long> USER_HOLDER = new ThreadLocal<>();
private static final ThreadLocal<String> TOKEN_HOLDER = new ThreadLocal<>();
/** /**
* 设置当前线程的用户ID * 设置当前线程的用户ID
@@ -25,9 +26,26 @@ public class UserContext {
} }
/** /**
* 清除当前线程的用户ID * 设置当前线程的token
* @param token 用户token
*/
public static void setToken(String token) {
TOKEN_HOLDER.set(token);
}
/**
* 获取当前线程的token
* @return 当前token如果未设置则返回null
*/
public static String getToken() {
return TOKEN_HOLDER.get();
}
/**
* 清除当前线程的用户ID和token
*/ */
public static void clear() { public static void clear() {
USER_HOLDER.remove(); USER_HOLDER.remove();
TOKEN_HOLDER.remove();
} }
} }

View File

@@ -4,11 +4,15 @@ import com.bao.dating.anno.Log;
import com.bao.dating.common.Result; import com.bao.dating.common.Result;
import com.bao.dating.common.ResultCode; import com.bao.dating.common.ResultCode;
import com.bao.dating.context.UserContext; import com.bao.dating.context.UserContext;
import com.bao.dating.pojo.dto.UserDeviceDTO;
import com.bao.dating.pojo.dto.UserInfoDTO; import com.bao.dating.pojo.dto.UserInfoDTO;
import com.bao.dating.pojo.dto.UserLoginDTO; import com.bao.dating.pojo.dto.UserLoginDTO;
import com.bao.dating.pojo.dto.UserLoginWithDeviceDTO;
import com.bao.dating.pojo.entity.User; import com.bao.dating.pojo.entity.User;
import com.bao.dating.pojo.vo.UserDeviceVO;
import com.bao.dating.pojo.vo.UserInfoVO; import com.bao.dating.pojo.vo.UserInfoVO;
import com.bao.dating.pojo.vo.UserLoginVO; import com.bao.dating.pojo.vo.UserLoginVO;
import com.bao.dating.service.UserDeviceService;
import com.bao.dating.service.UserService; import com.bao.dating.service.UserService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -30,14 +34,47 @@ public class UserController {
@Autowired @Autowired
private UserService userService; private UserService userService;
@Autowired
private UserDeviceService userDeviceService;
/** /**
* 登录 * 登录(带设备信息,推荐)
* @param userLoginDTO 登录参数 * @param loginDTO 登录参数(包含设备信息)
* @param request 请求
*/ */
@PostMapping("/login") @PostMapping("/login")
public Result<UserLoginVO> login(@RequestBody UserLoginDTO userLoginDTO) { public Result<UserLoginVO> login(@RequestBody UserLoginWithDeviceDTO loginDTO, HttpServletRequest request) {
UserLoginVO userloginVO = userService.userLogin(userLoginDTO); UserLoginDTO userLoginDTO = new UserLoginDTO();
return Result.success(ResultCode.SUCCESS, "登录成功", userloginVO); userLoginDTO.setUsername(loginDTO.getUsername());
userLoginDTO.setPassword(loginDTO.getPassword());
UserLoginVO userLoginVO = userService.userLogin(userLoginDTO);
// 记录设备信息
if (loginDTO.getDeviceId() != null && !loginDTO.getDeviceId().isEmpty()) {
UserDeviceDTO deviceDTO = new UserDeviceDTO();
deviceDTO.setDeviceId(loginDTO.getDeviceId());
deviceDTO.setDeviceType(loginDTO.getDeviceType());
deviceDTO.setDeviceName(loginDTO.getDeviceName());
deviceDTO.setDeviceBrand(loginDTO.getDeviceBrand());
deviceDTO.setOsVersion(loginDTO.getOsVersion());
deviceDTO.setAppVersion(loginDTO.getAppVersion());
deviceDTO.setIpAddress(getClientIp(request));
userDeviceService.recordDevice(userLoginVO.getUserId(), deviceDTO, userLoginVO.getToken());
}
return Result.success(ResultCode.SUCCESS, "登录成功", userLoginVO);
}
/**
* 简单登录(不记录设备信息)
* @param userLoginDTO 登录参数
*/
@PostMapping("/simple-login")
public Result<UserLoginVO> simpleLogin(@RequestBody UserLoginDTO userLoginDTO) {
UserLoginVO userLoginVO = userService.userLogin(userLoginDTO);
return Result.success(ResultCode.SUCCESS, "登录成功", userLoginVO);
} }
/** /**
@@ -215,4 +252,107 @@ public class UserController {
boolean online = userService.isUserOnline(userId); boolean online = userService.isUserOnline(userId);
return Result.success(ResultCode.SUCCESS, "查询成功", online); return Result.success(ResultCode.SUCCESS, "查询成功", online);
} }
// ==================== 设备管理接口 ====================
/**
* 获取用户所有登录设备
* @return 设备列表
*/
@GetMapping("/devices")
public Result<List<UserDeviceVO>> getUserDevices() {
Long userId = UserContext.getUserId();
List<UserDeviceVO> devices = userDeviceService.getUserDevices(userId);
return Result.success(ResultCode.SUCCESS, "获取成功", devices);
}
/**
* 强制下线某设备
* @param deviceId 设备ID
* @return 操作结果
*/
@PostMapping("/devices/kick/{deviceId}")
public Result<Void> kickDevice(@PathVariable String deviceId) {
Long userId = UserContext.getUserId();
boolean success = userDeviceService.kickDevice(userId, deviceId);
if (success) {
return Result.success(ResultCode.SUCCESS, "设备已下线", null);
} else {
return Result.error(ResultCode.FAIL, "设备不存在或无法下线");
}
}
/**
* 下线其他所有设备(除当前设备外)
* @return 被下线的设备数量
*/
@PostMapping("/devices/kick-others")
public Result<Integer> kickOtherDevices() {
Long userId = UserContext.getUserId();
// 获取当前设备
UserDeviceVO currentDevice = userDeviceService.getDeviceByToken(
UserContext.getToken() != null ? UserContext.getToken() : ""
);
String currentDeviceId = currentDevice != null ? currentDevice.getDeviceId() : null;
if (currentDeviceId == null) {
// 如果找不到当前设备,查询最新的设备
List<UserDeviceVO> devices = userDeviceService.getUserDevices(userId);
if (!devices.isEmpty()) {
currentDeviceId = devices.get(0).getDeviceId();
}
}
if (currentDeviceId == null) {
return Result.error(ResultCode.FAIL, "未找到设备");
}
int count = userDeviceService.kickOtherDevices(userId, currentDeviceId);
return Result.success(ResultCode.SUCCESS, "已下线 " + count + " 个设备", count);
}
/**
* 获取当前设备信息
* @return 当前设备信息
*/
@GetMapping("/devices/current")
public Result<UserDeviceVO> getCurrentDevice() {
String token = UserContext.getToken();
if (token == null) {
return Result.error(ResultCode.FAIL, "未登录");
}
UserDeviceVO device = userDeviceService.getDeviceByToken(token);
return Result.success(ResultCode.SUCCESS, "获取成功", device);
}
// ==================== 工具方法 ====================
/**
* 获取客户端IP地址
* @param request 请求
* @return IP地址
*/
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_CLIENT_IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
// 多个代理时第一个IP为真实IP
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
}
} }

View File

@@ -0,0 +1,108 @@
package com.bao.dating.controller;
import com.bao.dating.common.Result;
import com.bao.dating.common.ResultCode;
import com.bao.dating.context.UserContext;
import com.bao.dating.pojo.dto.UserDeviceDTO;
import com.bao.dating.pojo.vo.UserDeviceVO;
import com.bao.dating.service.UserDeviceService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 用户设备管理控制器
* @author KilLze
*/
@RestController
@RequestMapping("/user/device")
public class UserDeviceController {
@Autowired
private UserDeviceService userDeviceService;
/**
* 获取用户所有登录设备
* @return 设备列表
*/
@GetMapping("/list")
public Result<List<UserDeviceVO>> getUserDevices() {
Long userId = UserContext.getUserId();
List<UserDeviceVO> devices = userDeviceService.getUserDevices(userId);
return Result.success(ResultCode.SUCCESS, "获取成功", devices);
}
/**
* 强制下线某设备
* @param deviceId 设备ID
* @return 操作结果
*/
@PostMapping("/kick/{deviceId}")
public Result<Void> kickDevice(@PathVariable String deviceId) {
Long userId = UserContext.getUserId();
boolean success = userDeviceService.kickDevice(userId, deviceId);
if (success) {
return Result.success(ResultCode.SUCCESS, "设备已下线", null);
} else {
return Result.error(ResultCode.FAIL, "设备不存在或无法下线");
}
}
/**
* 设置当前设备
* @param deviceId 设备ID
* @return 操作结果
*/
@PostMapping("/current/{deviceId}")
public Result<Void> setCurrentDevice(@PathVariable String deviceId) {
Long userId = UserContext.getUserId();
boolean success = userDeviceService.setCurrentDevice(userId, deviceId);
if (success) {
return Result.success(ResultCode.SUCCESS, "设置成功", null);
} else {
return Result.error(ResultCode.FAIL, "设备不存在");
}
}
/**
* 删除设备记录(不能删除当前设备)
* @param deviceId 设备ID
* @return 操作结果
*/
@DeleteMapping("/{deviceId}")
public Result<Void> deleteDevice(@PathVariable String deviceId) {
Long userId = UserContext.getUserId();
boolean success = userDeviceService.deleteDevice(userId, deviceId);
if (success) {
return Result.success(ResultCode.SUCCESS, "删除成功", null);
} else {
return Result.error(ResultCode.FAIL, "设备不存在或无法删除当前设备");
}
}
/**
* 下线其他所有设备(除当前设备外)
* @return 被下线的设备数量
*/
@PostMapping("/kick-others")
public Result<Integer> kickOtherDevices() {
Long userId = UserContext.getUserId();
// 获取当前设备ID
List<UserDeviceVO> devices = userDeviceService.getUserDevices(userId);
String currentDeviceId = null;
for (UserDeviceVO device : devices) {
if (Boolean.TRUE.equals(device.getIsCurrent())) {
currentDeviceId = device.getDeviceId();
break;
}
}
if (currentDeviceId == null) {
return Result.error(ResultCode.FAIL, "未找到当前设备");
}
int count = userDeviceService.kickOtherDevices(userId, currentDeviceId);
return Result.success(ResultCode.SUCCESS, "已下线 " + count + " 个设备", count);
}
}

View File

@@ -98,6 +98,8 @@ public class TokenInterceptor implements HandlerInterceptor {
log.info("用户: {}", userId); log.info("用户: {}", userId);
// 保存 userId 到 ThreadLocal // 保存 userId 到 ThreadLocal
UserContext.setUserId(userId); UserContext.setUserId(userId);
// 保存 token 到 ThreadLocal
UserContext.setToken(token);
return true; return true;
} catch (Exception e) { } catch (Exception e) {
log.error("Token 校验失败: {}", e.getMessage()); log.error("Token 校验失败: {}", e.getMessage());

View File

@@ -0,0 +1,38 @@
package com.bao.dating.mapper;
import com.bao.dating.pojo.entity.UserDevice;
import com.bao.dating.pojo.vo.UserDeviceVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 用户设备Mapper
* @author KilLze
*/
@Mapper
public interface UserDeviceMapper {
int insert(UserDevice userDevice);
UserDevice selectByDeviceId(@Param("deviceId") String deviceId);
List<UserDeviceVO> selectByUserId(@Param("userId") Long userId);
int updateStatus(@Param("deviceId") String deviceId, @Param("status") Integer status);
int updateLastActiveAt(@Param("deviceId") String deviceId);
int clearCurrentDevice(@Param("userId") Long userId);
int setCurrentDevice(@Param("deviceId") String deviceId);
int deleteByDeviceId(@Param("deviceId") String deviceId);
UserDevice selectByToken(@Param("token") String token);
int update(UserDevice userDevice);
int deleteByUserId(@Param("userId") Long userId);
}

View File

@@ -0,0 +1,51 @@
package com.bao.dating.pojo.dto;
import lombok.Data;
/**
* 用户设备DTO
* @author KilLze
*/
@Data
public class UserDeviceDTO {
/**
* 设备ID唯一标识
*/
private String deviceId;
/**
* 设备类型1-Android, 2-iOS, 3-Web, 4-其他
*/
private Integer deviceType;
/**
* 设备名称iPhone 14 Pro
*/
private String deviceName;
/**
* 设备品牌Apple, Xiaomi
*/
private String deviceBrand;
/**
* 操作系统版本
*/
private String osVersion;
/**
* 浏览器/应用版本
*/
private String appVersion;
/**
* 设备IP
*/
private String ipAddress;
/**
* 设备位置根据IP解析可选
*/
private String location;
}

View File

@@ -0,0 +1,51 @@
package com.bao.dating.pojo.dto;
import lombok.Data;
/**
* 登录请求DTO带设备信息
* @author KilLze
*/
@Data
public class UserLoginWithDeviceDTO {
/**
* 用户名
*/
private String username;
/**
* 密码
*/
private String password;
/**
* 设备ID唯一标识
*/
private String deviceId;
/**
* 设备类型1-Android, 2-iOS, 3-Web, 4-其他
*/
private Integer deviceType;
/**
* 设备名称iPhone 14 Pro
*/
private String deviceName;
/**
* 设备品牌Apple, Xiaomi
*/
private String deviceBrand;
/**
* 操作系统版本
*/
private String osVersion;
/**
* 浏览器/应用版本
*/
private String appVersion;
}

View File

@@ -0,0 +1,78 @@
package com.bao.dating.pojo.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 用户设备信息VO
* @author KilLze
*/
@Data
public class UserDeviceVO {
/**
* 主键ID
*/
private Long id;
/**
* 设备ID
*/
private String deviceId;
/**
* 设备类型1-Android, 2-iOS, 3-Web, 4-其他
*/
private String deviceType;
/**
* 设备名称
*/
private String deviceName;
/**
* 设备品牌
*/
private String deviceBrand;
/**
* 操作系统版本
*/
private String osVersion;
/**
* 应用版本
*/
private String appVersion;
/**
* IP地址
*/
private String ipAddress;
/**
* 设备位置
*/
private String location;
/**
* 是否当前设备
*/
private Boolean isCurrent;
/**
* 是否在线
*/
private Boolean isOnline;
/**
* 最后活跃时间
*/
private LocalDateTime lastActiveAt;
/**
* 登录时间
*/
private LocalDateTime loginAt;
}

View File

@@ -0,0 +1,74 @@
package com.bao.dating.service;
import com.bao.dating.pojo.dto.UserDeviceDTO;
import com.bao.dating.pojo.vo.UserDeviceVO;
import java.util.List;
/**
* 用户设备服务接口
* @author KilLze
*/
public interface UserDeviceService {
/**
* 记录用户登录设备
* @param userId 用户ID
* @param deviceDTO 设备信息
* @param token 登录Token
* @return 设备信息
*/
UserDeviceVO recordDevice(Long userId, UserDeviceDTO deviceDTO, String token);
/**
* 获取用户所有登录设备
* @param userId 用户ID
* @return 设备列表
*/
List<UserDeviceVO> getUserDevices(Long userId);
/**
* 强制下线某设备
* @param userId 用户ID
* @param deviceId 设备ID
* @return 是否成功
*/
boolean kickDevice(Long userId, String deviceId);
/**
* 设置某设备为当前设备
* @param userId 用户ID
* @param deviceId 设备ID
* @return 是否成功
*/
boolean setCurrentDevice(Long userId, String deviceId);
/**
* 删除设备记录
* @param userId 用户ID
* @param deviceId 设备ID
* @return 是否成功
*/
boolean deleteDevice(Long userId, String deviceId);
/**
* 更新设备活跃时间
* @param token 用户Token
*/
void updateDeviceActive(String token);
/**
* 根据Token获取设备信息
* @param token 用户Token
* @return 设备信息
*/
UserDeviceVO getDeviceByToken(String token);
/**
* 下线其他所有设备(除当前设备外)
* @param userId 用户ID
* @param currentDeviceId 当前设备ID
* @return 被下线的设备数量
*/
int kickOtherDevices(Long userId, String currentDeviceId);
}

View File

@@ -0,0 +1,224 @@
package com.bao.dating.service.impl;
import com.bao.dating.context.UserContext;
import com.bao.dating.mapper.UserDeviceMapper;
import com.bao.dating.mapper.UserMapper;
import com.bao.dating.pojo.dto.UserDeviceDTO;
import com.bao.dating.pojo.entity.User;
import com.bao.dating.pojo.entity.UserDevice;
import com.bao.dating.pojo.vo.UserDeviceVO;
import com.bao.dating.service.UserDeviceService;
import com.bao.dating.session.WsSessionManager;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
/**
* 用户设备服务实现类
* @author KilLze
*/
@Service
public class UserDeviceServiceImpl implements UserDeviceService {
@Autowired
private UserDeviceMapper userDeviceMapper;
@Autowired
private UserMapper userMapper;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private WsSessionManager wsSessionManager;
@Override
public UserDeviceVO recordDevice(Long userId, UserDeviceDTO deviceDTO, String token) {
// 先查找是否已存在该设备
UserDevice existingDevice = userDeviceMapper.selectByDeviceId(deviceDTO.getDeviceId());
LocalDateTime now = LocalDateTime.now();
if (existingDevice != null) {
// 设备已存在,更新信息
existingDevice.setToken(token);
existingDevice.setStatus(1); // 在线
existingDevice.setIsCurrent(1); // 设为当前设备
existingDevice.setLastActiveAt(now);
existingDevice.setIpAddress(deviceDTO.getIpAddress());
existingDevice.setLocation(deviceDTO.getLocation());
userDeviceMapper.update(existingDevice);
// 清除该用户其他设备的当前标记
userDeviceMapper.clearCurrentDevice(userId);
userDeviceMapper.setCurrentDevice(deviceDTO.getDeviceId());
UserDeviceVO vo = new UserDeviceVO();
BeanUtils.copyProperties(existingDevice, vo);
vo.setDeviceType(getDeviceTypeName(existingDevice.getDeviceType()));
vo.setIsCurrent(true);
vo.setIsOnline(true);
return vo;
}
// 新设备,插入记录
UserDevice userDevice = new UserDevice();
userDevice.setUserId(userId);
userDevice.setDeviceId(deviceDTO.getDeviceId());
userDevice.setDeviceType(deviceDTO.getDeviceType());
userDevice.setDeviceName(deviceDTO.getDeviceName());
userDevice.setDeviceBrand(deviceDTO.getDeviceBrand());
userDevice.setOsVersion(deviceDTO.getOsVersion());
userDevice.setAppVersion(deviceDTO.getAppVersion());
userDevice.setIpAddress(deviceDTO.getIpAddress());
userDevice.setLocation(deviceDTO.getLocation());
userDevice.setToken(token);
userDevice.setStatus(1); // 在线
userDevice.setIsCurrent(1); // 设为当前设备
userDevice.setLastActiveAt(now);
userDevice.setLoginAt(now);
// 清除该用户其他设备的当前标记
userDeviceMapper.clearCurrentDevice(userId);
userDeviceMapper.insert(userDevice);
UserDeviceVO vo = new UserDeviceVO();
BeanUtils.copyProperties(userDevice, vo);
vo.setDeviceType(getDeviceTypeName(userDevice.getDeviceType()));
vo.setIsCurrent(true);
vo.setIsOnline(true);
return vo;
}
@Override
public List<UserDeviceVO> getUserDevices(Long userId) {
List<UserDeviceVO> devices = userDeviceMapper.selectByUserId(userId);
// 格式化设备类型
for (UserDeviceVO device : devices) {
device.setDeviceType(getDeviceTypeName(device.getDeviceType()));
}
return devices;
}
@Override
public boolean kickDevice(Long userId, String deviceId) {
UserDevice device = userDeviceMapper.selectByDeviceId(deviceId);
// 验证设备属于该用户
if (device == null || !device.getUserId().equals(userId)) {
return false;
}
// 删除设备记录
userDeviceMapper.deleteByDeviceId(deviceId);
// 从Redis中删除该设备的登录状态
String redisKey = "login:token:" + userId;
redisTemplate.delete(redisKey);
// 如果有WebSocket连接关闭连接
wsSessionManager.removeSession(device.getToken());
return true;
}
@Override
public boolean setCurrentDevice(Long userId, String deviceId) {
UserDevice device = userDeviceMapper.selectByDeviceId(deviceId);
if (device == null || !device.getUserId().equals(userId)) {
return false;
}
// 清除所有当前设备标记
userDeviceMapper.clearCurrentDevice(userId);
// 设置新的当前设备
userDeviceMapper.setCurrentDevice(deviceId);
return true;
}
@Override
public boolean deleteDevice(Long userId, String deviceId) {
UserDevice device = userDeviceMapper.selectByDeviceId(deviceId);
if (device == null || !device.getUserId().equals(userId)) {
return false;
}
// 不能删除当前设备
if (device.getIsCurrent() == 1) {
return false;
}
userDeviceMapper.deleteByDeviceId(deviceId);
return true;
}
@Override
public void updateDeviceActive(String token) {
if (token != null && !token.isEmpty()) {
userDeviceMapper.updateLastActiveAt(token);
}
}
@Override
public UserDeviceVO getDeviceByToken(String token) {
UserDevice device = userDeviceMapper.selectByToken(token);
if (device == null) {
return null;
}
UserDeviceVO vo = new UserDeviceVO();
BeanUtils.copyProperties(device, vo);
vo.setDeviceType(getDeviceTypeName(device.getDeviceType()));
vo.setIsOnline(device.getStatus() == 1);
vo.setIsCurrent(device.getIsCurrent() == 1);
return vo;
}
@Override
public int kickOtherDevices(Long userId, String currentDeviceId) {
List<UserDeviceVO> devices = userDeviceMapper.selectByUserId(userId);
int count = 0;
for (UserDeviceVO device : devices) {
if (!device.getDeviceId().equals(currentDeviceId)) {
// 踢掉设备
userDeviceMapper.deleteByDeviceId(device.getDeviceId());
// 清除登录状态
String redisKey = "login:token:" + userId;
redisTemplate.delete(redisKey);
// 关闭WebSocket连接
wsSessionManager.removeSession(device.getDeviceId());
count++;
}
}
return count;
}
/**
* 获取设备类型名称
*/
private String getDeviceTypeName(Integer deviceType) {
if (deviceType == null) {
return "未知";
}
switch (deviceType) {
case 1: return "Android";
case 2: return "iOS";
case 3: return "Web";
case 4: return "其他";
default: return "未知";
}
}
}

View File

@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.bao.dating.mapper.UserDeviceMapper">
<!--插入设备信息-->
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO user_device (
user_id, device_id, device_type, device_name, device_brand,
os_version, app_version, ip_address, location, token,
status, is_current, last_active_at, login_at, created_at
) VALUES (
#{userId}, #{deviceId}, #{deviceType}, #{deviceName}, #{deviceBrand},
#{osVersion}, #{appVersion}, #{ipAddress}, #{location}, #{token},
#{status}, #{isCurrent}, #{lastActiveAt}, #{loginAt}, NOW()
)
</insert>
<!--根据设备ID查询-->
<select id="selectByDeviceId" resultType="com.bao.dating.pojo.entity.UserDevice">
SELECT * FROM user_device WHERE device_id = #{deviceId}
</select>
<!--根据用户ID查询所有设备-->
<select id="selectByUserId" resultType="com.bao.dating.pojo.vo.UserDeviceVO">
SELECT
id, device_id, device_type, device_name, device_brand,
os_version, app_version, ip_address, location,
is_current = 1 as is_current,
status = 1 as is_online,
last_active_at, login_at
FROM user_device
WHERE user_id = #{userId}
ORDER BY is_current DESC, last_active_at DESC
</select>
<!--更新设备状态-->
<update id="updateStatus">
UPDATE user_device SET status = #{status} WHERE device_id = #{deviceId}
</update>
<!--更新最后活跃时间-->
<update id="updateLastActiveAt">
UPDATE user_device SET last_active_at = NOW() WHERE device_id = #{deviceId}
</update>
<!--清除用户当前设备标记-->
<update id="clearCurrentDevice">
UPDATE user_device SET is_current = 0 WHERE user_id = #{userId}
</update>
<!--设置当前设备-->
<update id="setCurrentDevice">
UPDATE user_device SET is_current = 1 WHERE device_id = #{deviceId}
</update>
<!--根据Token查询-->
<select id="selectByToken" resultType="com.bao.dating.pojo.entity.UserDevice">
SELECT * FROM user_device WHERE token = #{token}
</select>
<!--更新设备信息-->
<update id="update" parameterType="com.bao.dating.pojo.entity.UserDevice">
UPDATE user_device SET
device_name = #{deviceName},
device_brand = #{deviceBrand},
os_version = #{osVersion},
app_version = #{appVersion},
ip_address = #{ipAddress},
location = #{location},
last_active_at = NOW()
WHERE device_id = #{deviceId}
</update>
<!--删除设备-->
<delete id="deleteByDeviceId">
DELETE FROM user_device WHERE device_id = #{deviceId}
</delete>
<!--删除用户所有设备-->
<delete id="deleteByUserId">
DELETE FROM user_device WHERE user_id = #{userId}
</delete>
</mapper>

View File

@@ -0,0 +1,22 @@
-- 用户设备登录记录表
CREATE TABLE IF NOT EXISTS `user_device` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`device_id` VARCHAR(64) NOT NULL COMMENT '设备唯一标识',
`device_type` TINYINT DEFAULT 4 COMMENT '设备类型1-Android, 2-iOS, 3-Web, 4-其他',
`device_name` VARCHAR(128) DEFAULT NULL COMMENT '设备名称',
`device_brand` VARCHAR(64) DEFAULT NULL COMMENT '设备品牌',
`os_version` VARCHAR(32) DEFAULT NULL COMMENT '操作系统版本',
`app_version` VARCHAR(32) DEFAULT NULL COMMENT '应用版本',
`ip_address` VARCHAR(64) DEFAULT NULL COMMENT 'IP地址',
`location` VARCHAR(128) DEFAULT NULL COMMENT '设备位置',
`token` VARCHAR(512) DEFAULT NULL COMMENT '登录Token',
`status` TINYINT DEFAULT 1 COMMENT '在线状态1-在线, 0-离线',
`is_current` TINYINT DEFAULT 0 COMMENT '是否当前设备1-是, 0-否',
`last_active_at` DATETIME DEFAULT NULL COMMENT '最后活跃时间',
`login_at` DATETIME DEFAULT NULL COMMENT '登录时间',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
UNIQUE KEY `uk_device_id` (`device_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_token` (`token`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户登录设备记录表';