20 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
KilLze
2224b43fcb 修复AI的小巧思3 2026-01-20 15:16:05 +08:00
KilLze
9c1b701594 删除AI的小巧思2 2026-01-20 15:13:58 +08:00
KilLze
b77952164b 删除AI的小巧思 2026-01-20 15:13:35 +08:00
KilLze
5210ea9554 把日志记录入库删了() 2026-01-20 14:57:58 +08:00
bao
6b7f6947db 合并 2026-01-20 14:51:53 +08:00
bao
1f81b6fbc1 feat: 添加用户封禁管理功能 2026-01-20 14:45:01 +08:00
bao
d53bc3966c Merge origin/123321 into master 2026-01-19 08:25:02 +08:00
KilLze
39af4e6596 修该死的bug 2026-01-14 11:44:36 +08:00
KilLze
ea5c95584d 优化会话列表,添加用户在线状态 2026-01-14 11:31:10 +08:00
KilLze
e170016019 完成用户在线状态查询 2026-01-14 11:21:06 +08:00
88abc41a9e 下载图片+水印(作者ID+作者名) 2026-01-13 13:48:26 +08:00
KilLze
6d95b8d391 完成数据库定时解封低频同步(凌晨3点),主要还是依赖redis实时同步 2026-01-13 01:34:05 +08:00
KilLze
1c2a4edae9 完成用户封禁,解封,封禁查询(由于没有管理员,所以现在先用普通用户测试封禁) 2026-01-13 01:29:34 +08:00
KilLze
088c94e723 用户封禁拦截器,登录验证完成 2026-01-13 00:32:30 +08:00
KilLze
a3d0d7423c 用户封禁拦截器,登录验证完成 2026-01-13 00:32:17 +08:00
37 changed files with 1625 additions and 77 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年

41
pom.xml
View File

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

View File

@@ -0,0 +1,16 @@
package com.bao.dating.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "aliyun.oss")
public class AliyunOSSConfig {
private String endpoint;
private String accessKeyId;
private String accessKeySecret;
private String bucketName;
}

View File

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

View File

@@ -34,8 +34,8 @@ public class WebConfig implements WebMvcConfigurer {
"/user/emailLogin", "/user/emailLogin",
"/api/verification/send-email-code", "/api/verification/send-email-code",
"/ip/location", "/ip/location",
"/user/login", "/user/sendCode",
"/user/sendCode" "/download/{postId}"
); );
} }
} }

View File

@@ -1,12 +1,20 @@
package com.bao.dating.context; package com.bao.dating.context;
/** /**
* 用户上下文类用于保存当前线程的用户ID * 用户上下文类用于保存当前线程的用户ID和token
* @author lenovo * @author lenovo
*/ */
public class UserContext { public class UserContext {
/**
* 当前线程的用户ID
*/
private static final ThreadLocal<Long> USER_HOLDER = new ThreadLocal<>(); private static final ThreadLocal<Long> USER_HOLDER = new ThreadLocal<>();
/**
* 当前线程的设备ID
*/
private static final ThreadLocal<String> DEVICE_HOLDER = new ThreadLocal<>();
/** /**
* 设置当前线程的用户ID * 设置当前线程的用户ID
@@ -25,9 +33,26 @@ public class UserContext {
} }
/** /**
* 清除当前线程的用户ID * 设置当前线程的设备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() { public static void clear() {
USER_HOLDER.remove(); USER_HOLDER.remove();
DEVICE_HOLDER.remove();
} }
} }

View File

@@ -0,0 +1,51 @@
package com.bao.dating.controller;
import com.bao.dating.common.Result;
import com.bao.dating.common.ResultCode;
import com.bao.dating.pojo.dto.UserBanDTO;
import com.bao.dating.pojo.entity.UserBan;
import com.bao.dating.service.UserBanService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
/**
* 管理员控制器
* @author lenovo
*/
@RestController
@RequestMapping("/admin")
public class AdminController {
@Autowired
private UserBanService userBanService;
/**
* 封禁用户
*/
@PostMapping("/{userId}/ban")
public Result<?> banUser(@PathVariable Long userId,
@RequestBody UserBanDTO userBanDTO) {
userBanDTO.setUserId(userId);
userBanService.banUser(userBanDTO);
return Result.success(ResultCode.SUCCESS, "封禁成功");
}
/**
* 解封用户
*/
@PostMapping("/{userId}/unban")
public Result<?> unbanUser(@PathVariable Long userId) {
userBanService.unbanUser(userId);
return Result.success(ResultCode.SUCCESS, "解封成功");
}
/**
* 查询封禁状态
*/
@GetMapping("/{userId}/banInfo")
public Result<UserBan> banInfo(@PathVariable Long userId) {
UserBan ban = userBanService.getActiveBan(userId);
return Result.success(ResultCode.SUCCESS, "查询成功", ban);
}
}

View File

@@ -12,6 +12,9 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import javax.imageio.ImageIO;
import javax.servlet.http.HttpServletResponse;
import java.awt.image.BufferedImage;
import java.util.List; import java.util.List;
/** /**
@@ -83,4 +86,20 @@ public class PostController {
PostEditVO result = postService.updatePost(postId, postRequestDTO); PostEditVO result = postService.updatePost(postId, postRequestDTO);
return Result.success(ResultCode.SUCCESS, "动态更新成功", result); return Result.success(ResultCode.SUCCESS, "动态更新成功", result);
} }
@GetMapping("/download/{postId}")
public void downloadPostImage(@PathVariable Long postId, HttpServletResponse response) throws Exception {
try {
//Service 返回已经加好水印的图片
BufferedImage image = postService.downloadWithWatermark(postId);
//设置响应头,触发浏览器下载
response.setContentType("image/jpeg");
response.setHeader("Content-Disposition", "attachment;filename=post_" + postId + ".jpg");
//输出到浏览器
ImageIO.write(image, "jpg", response.getOutputStream());
response.getOutputStream().flush();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
} }

View File

@@ -1,11 +1,13 @@
package com.bao.dating.controller; package com.bao.dating.controller;
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.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.UserService; import com.bao.dating.service.UserService;
@@ -28,15 +30,15 @@ public class UserController {
@Autowired @Autowired
private UserService userService; private UserService userService;
/** /**
* 登录 * 简单登录(不记录设备信息)
* @param userLoginDTO 登录参数 * @param userLoginDTO 登录参数
*/ */
@PostMapping("/login") @PostMapping("/login")
public Result<UserLoginVO> login(@RequestBody UserLoginDTO userLoginDTO) { public Result<UserLoginVO> simpleLogin(@RequestBody UserLoginDTO userLoginDTO) {
UserLoginVO userloginVO = userService.userLogin(userLoginDTO); UserLoginVO userLoginVO = userService.userLogin(userLoginDTO);
return Result.success(ResultCode.SUCCESS, "登录成功", userloginVO); return Result.success(ResultCode.SUCCESS, "登录成功", userLoginVO);
} }
/** /**
@@ -46,7 +48,8 @@ public class UserController {
@PostMapping("/logout") @PostMapping("/logout")
public Result<Void> logout(HttpServletRequest request) { public Result<Void> logout(HttpServletRequest request) {
String token = request.getHeader("token"); String token = request.getHeader("token");
userService.logout(token); String deviceId = request.getHeader("deviceId");
userService.logout(token, deviceId);
return Result.success(ResultCode.SUCCESS,"退出登录成功",null); return Result.success(ResultCode.SUCCESS,"退出登录成功",null);
} }
@@ -66,7 +69,6 @@ public class UserController {
* @param file 头像文件 * @param file 头像文件
* @return 上传后的文件URL列表 * @return 上传后的文件URL列表
*/ */
@Log
@PostMapping(value = "/info/uploadAvatar", consumes = "multipart/form-data") @PostMapping(value = "/info/uploadAvatar", consumes = "multipart/form-data")
public Result<String> uploadAvatar(@RequestParam("file") MultipartFile file) { public Result<String> uploadAvatar(@RequestParam("file") MultipartFile file) {
String fileUrl = userService.uploadAvatar(file); String fileUrl = userService.uploadAvatar(file);
@@ -78,7 +80,6 @@ public class UserController {
* @param file 背景文件 * @param file 背景文件
* @return 上传后的文件URL列表 * @return 上传后的文件URL列表
*/ */
@Log
@PostMapping(value = "/info/uploadBackground", consumes = "multipart/form-data") @PostMapping(value = "/info/uploadBackground", consumes = "multipart/form-data")
public Result<String> uploadBackground(@RequestParam("file") MultipartFile file) { public Result<String> uploadBackground(@RequestParam("file") MultipartFile file) {
String fileUrl = userService.uploadBackground(file); String fileUrl = userService.uploadBackground(file);
@@ -90,7 +91,6 @@ public class UserController {
* @param userInfoUpdateDTO 用户信息更新参数 * @param userInfoUpdateDTO 用户信息更新参数
* @return 更新后的用户信息 * @return 更新后的用户信息
*/ */
@Log
@PostMapping("/info/update") @PostMapping("/info/update")
public Result<UserInfoVO> userInfoUpdate(@RequestBody UserInfoDTO userInfoUpdateDTO) { public Result<UserInfoVO> userInfoUpdate(@RequestBody UserInfoDTO userInfoUpdateDTO) {
Long userId = UserContext.getUserId(); Long userId = UserContext.getUserId();
@@ -203,4 +203,44 @@ public class UserController {
return Result.success(ResultCode.SUCCESS,"用户登录成功",userLoginVO); return Result.success(ResultCode.SUCCESS,"用户登录成功",userLoginVO);
} }
/**
* 判断用户是否在线
* @param userId 用户ID
* @return 用户是否在线
*/
@GetMapping("/{userId}/online")
public Result<Boolean> isUserOnline(@PathVariable Long userId) {
boolean online = userService.isUserOnline(userId);
return Result.success(ResultCode.SUCCESS, "查询成功", online);
}
/**
* 获取客户端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

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

View File

@@ -5,6 +5,7 @@ import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* 动态Mapper * 动态Mapper
@@ -117,4 +118,12 @@ public interface PostMapper {
* @return 影响行数 * @return 影响行数
*/ */
int decreaseFavoriteCount(Long postId); int decreaseFavoriteCount(Long postId);
/**
* 根据动态id查询用户名和媒体信息
*
* @param postId 动态id
* @return 用户名和媒体信息
*/
Map<String, Object> getUsernameByUserId(Long postId);
} }

View File

@@ -0,0 +1,43 @@
package com.bao.dating.mapper;
import com.bao.dating.pojo.entity.UserBan;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface UserBanMapper {
/**
* 新增封禁记录
* @param userBan 封禁记录
* @return 影响行数
*/
int insertBan(UserBan userBan);
/**
* 查询是否存在生效中的封禁
* @param userId 用户ID
* @return 存在返回1不存在返回0
*/
int existsActiveBan(@Param("userId") Long userId);
/**
* 查询生效中的封禁记录
* @param userId 用户ID
* @return 封禁记录
*/
UserBan selectActiveBan(@Param("userId") Long userId);
/**
* 解封用户
* @param userId 用户ID
* @return 影响行数
*/
int unbanUser(@Param("userId") Long userId);
/**
* 定时任务:过期自动解封
* @return 影响行数
*/
int updateExpiredBans();
}

View File

@@ -0,0 +1,13 @@
package com.bao.dating.pojo.dto;
import lombok.Data;
/**
* 用户封禁数据传输对象
* @author KilLze
*/
@Data
public class UserBanDTO {
private Long userId;
private String reason;
private Integer banDays;
}

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

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

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,30 @@
package com.bao.dating.pojo.entity;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 用户封禁记录
* @author KilLze
*/
@Data
public class UserBan {
private Long id;
private Long userId;
private String reason;
private LocalDateTime banStartTime;
private LocalDateTime banEndTime;
/**
* 1:封禁中 0:已解封
*/
private Integer status;
private LocalDateTime createTime;
}

View File

@@ -29,4 +29,6 @@ public class ChatSessionsVO {
private Integer topStatus; private Integer topStatus;
/** 免打扰状态 */ /** 免打扰状态 */
private Integer muteStatus; private Integer muteStatus;
/** 会话状态 */
private Boolean online;
} }

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

@@ -26,4 +26,5 @@ public class UserInfoVO implements Serializable {
private LocalDateTime createdAt; private LocalDateTime createdAt;
private Double latitude; private Double latitude;
private Double longitude; private Double longitude;
private Boolean online;
} }

View File

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

View File

@@ -5,6 +5,7 @@ import com.bao.dating.pojo.entity.Post;
import com.bao.dating.pojo.vo.PostEditVO; import com.bao.dating.pojo.vo.PostEditVO;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.awt.image.BufferedImage;
import java.util.List; import java.util.List;
/** /**
@@ -55,4 +56,11 @@ public interface PostService {
* @return 用户id * @return 用户id
*/ */
Long selectUserIdByPostId(Long postId); Long selectUserIdByPostId(Long postId);
/**
* 下载动态图片并添加水印
* @param postId 动态ID
* @return 带水印的图片
*/
BufferedImage downloadWithWatermark(Long postId) throws Exception;
} }

View File

@@ -0,0 +1,31 @@
package com.bao.dating.service;
import com.bao.dating.pojo.dto.UserBanDTO;
import com.bao.dating.pojo.entity.UserBan;
/**
* 用户封禁服务接口
* @author KilLze
*/
public interface UserBanService {
/**
* 封禁用户
* @param userBanDTO 用户封禁信息
*
*/
void banUser(UserBanDTO userBanDTO);
/**
* 解封用户
* @param userId 用户ID
*/
void unbanUser(Long userId);
/**
* 查询封禁信息
* @param userId 用户ID
* @return 封禁信息
*/
UserBan getActiveBan(Long userId);
}

View File

@@ -25,7 +25,7 @@ public interface UserService {
* @param token 登录凭证 * @param token 登录凭证
* @return 注册结果 * @return 注册结果
*/ */
void logout(String token); void logout(String token, String deviceId);
/** /**
* 查询个人信息 * 查询个人信息
@@ -92,4 +92,11 @@ public interface UserService {
* @return 用户列表 * @return 用户列表
*/ */
List<UserInfoVO> findNearbyUsers(double lat,double lng,double radiusKm); List<UserInfoVO> findNearbyUsers(double lat,double lng,double radiusKm);
/**
* 判断用户是否在线
* @param userId 用户ID
* @return 是否在线
*/
boolean isUserOnline(Long userId);
} }

View File

@@ -236,6 +236,7 @@ public class ChatServiceImpl implements ChatService {
vo.setSessionName("用户" + session.getTargetUserId()); vo.setSessionName("用户" + session.getTargetUserId());
vo.setAvatarUrl(null); vo.setAvatarUrl(null);
} }
vo.setOnline(userService.isUserOnline(vo.getTargetUserId()));
return vo; return vo;
}).collect(Collectors.toList()); }).collect(Collectors.toList());
} }

View File

@@ -10,11 +10,13 @@ import com.bao.dating.mapper.PostLikeMapper;
import com.bao.dating.mapper.PostMapper; import com.bao.dating.mapper.PostMapper;
import com.bao.dating.pojo.dto.PostRequestDTO; import com.bao.dating.pojo.dto.PostRequestDTO;
import com.bao.dating.pojo.entity.Post; import com.bao.dating.pojo.entity.Post;
import com.bao.dating.pojo.entity.User;
import com.bao.dating.pojo.vo.PostEditVO; import com.bao.dating.pojo.vo.PostEditVO;
import com.bao.dating.service.PostService; import com.bao.dating.service.PostService;
import com.bao.dating.common.aliyun.AliOssUtil; import com.bao.dating.common.aliyun.AliOssUtil;
import com.bao.dating.service.UserService; import com.bao.dating.service.UserService;
import com.bao.dating.util.FileUtil; import com.bao.dating.util.FileUtil;
import com.bao.dating.util.WatermarkUtil;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -22,6 +24,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils; import org.springframework.util.CollectionUtils;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.awt.image.BufferedImage;
import java.io.IOException; import java.io.IOException;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -57,6 +60,9 @@ public class PostServiceImpl implements PostService {
@Autowired @Autowired
private CommentsMapper commentsMapper; private CommentsMapper commentsMapper;
@Autowired
private WatermarkUtil watermarkUtil;
/** /**
* 上传媒体文件 * 上传媒体文件
* @param files 媒体文件数组 * @param files 媒体文件数组
@@ -344,4 +350,61 @@ public class PostServiceImpl implements PostService {
return postMapper.selectUserIdByPostId(postId); return postMapper.selectUserIdByPostId(postId);
} }
/**
* 下载动态图片并添加水印
*
* @param postId 动态ID
* @return 带水印的图片
* @throws Exception
*/
@Override
public BufferedImage downloadWithWatermark(Long postId) throws Exception {
// 一次性查出 动态图片 + 作者信息
Map<String, Object> map = postMapper.getUsernameByUserId(postId);
if (map == null || map.isEmpty()) {
Post post = postMapper.selectById(postId);
if (post == null) {
throw new RuntimeException("未找到指定postId的帖子: " + postId);
}
throw new RuntimeException("未找到与postId相关的用户和媒体信息: " + postId);
}
String mediaUrl = (String) map.get("media_oss_keys");
String username = (String) map.get("user_name");
Object userIdObj = map.get("user_id");
if (mediaUrl == null || username == null || userIdObj == null) {
throw new RuntimeException("用户或媒体信息不完整: " + map);
}
mediaUrl = mediaUrl.trim();
if (mediaUrl.isEmpty()) {
throw new RuntimeException("媒体URL为空postId: " + postId);
}
Long userId = userIdObj instanceof Number
? ((Number) userIdObj).longValue()
: Long.valueOf(userIdObj.toString());
// 解析 OSS ObjectKey支持完整URL和直接存key两种
String cleanUrl = mediaUrl.split("\\?")[0]; // 去掉 ? 后面的参数
String objectKey;
if (cleanUrl.startsWith("http")) {
// https://xxx.oss-cn-xxx.aliyuncs.com/post/xxx.jpg → post/xxx.jpg
objectKey = cleanUrl.substring(cleanUrl.indexOf(".com/") + 5);
} else {
objectKey = cleanUrl;
}
if (objectKey.trim().isEmpty()) {
throw new RuntimeException("解析后的ObjectKey为空url: " + mediaUrl);
}
// 下载并动态加水印只给下载的人看OSS原图不改数据库不动
return watermarkUtil.downloadAndWatermark(objectKey, username, userId);
}
} }

View File

@@ -0,0 +1,74 @@
package com.bao.dating.service.impl;
import com.bao.dating.mapper.UserBanMapper;
import com.bao.dating.pojo.dto.UserBanDTO;
import com.bao.dating.pojo.entity.UserBan;
import com.bao.dating.service.UserBanService;
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.concurrent.TimeUnit;
@Service
public class UserBanServiceImpl implements UserBanService {
@Autowired
private UserBanMapper userBanMapper;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Override
public void banUser(UserBanDTO userBanDTO) {
// 已被封禁,直接拒绝
if (userBanMapper.existsActiveBan(userBanDTO.getUserId()) > 0) {
throw new RuntimeException("用户已处于封禁状态");
}
LocalDateTime now = LocalDateTime.now();
LocalDateTime endTime = userBanDTO.getBanDays() == null ? null : now.plusDays(userBanDTO.getBanDays());
// 1. 写数据库
UserBan ban = new UserBan();
ban.setUserId(userBanDTO.getUserId());
ban.setReason(userBanDTO.getReason());
ban.setBanStartTime(now);
ban.setBanEndTime(endTime);
ban.setStatus(1);
userBanMapper.insertBan(ban);
// 2. 写 Redis
String key = "user:ban:" + userBanDTO.getUserId();
if (userBanDTO.getBanDays() == null) {
redisTemplate.opsForValue().set(key, userBanDTO.getReason());
} else {
redisTemplate.opsForValue().set(key, userBanDTO.getReason(), userBanDTO.getBanDays(), TimeUnit.DAYS);
}
// 3. 踢下线
redisTemplate.delete("login:token:" + userBanDTO.getUserId());
}
/**
* 解封用户
*/
@Override
public void unbanUser(Long userId) {
// 更新数据库
userBanMapper.unbanUser(userId);
// 删除 Redis
redisTemplate.delete("user:ban:" + userId);
}
/**
* 获取用户封禁信息
*/
@Override
public UserBan getActiveBan(Long userId) {
return userBanMapper.selectActiveBan(userId);
}
}

View File

@@ -20,6 +20,7 @@ import com.bao.dating.util.CodeUtil;
import com.bao.dating.util.FileUtil; import com.bao.dating.util.FileUtil;
import com.bao.dating.util.JwtUtil; import com.bao.dating.util.JwtUtil;
import com.bao.dating.util.MD5Util; import com.bao.dating.util.MD5Util;
import com.bao.dating.util.UserBanUtil;
import io.jsonwebtoken.Claims; import io.jsonwebtoken.Claims;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -66,6 +67,9 @@ public class UserServiceImpl implements UserService {
@Autowired @Autowired
private VerificationCodeService verificationCodeService; private VerificationCodeService verificationCodeService;
@Autowired
private UserBanUtil userBanValidator;
/** /**
* 用户登录 * 用户登录
* *
@@ -78,6 +82,9 @@ public class UserServiceImpl implements UserService {
if (userLoginDTO == null || userLoginDTO.getUsername() == null || userLoginDTO.getPassword() == null) { if (userLoginDTO == null || userLoginDTO.getUsername() == null || userLoginDTO.getPassword() == null) {
throw new RuntimeException("用户名或密码不能为空"); throw new RuntimeException("用户名或密码不能为空");
} }
if (userLoginDTO.getDeviceId() == null || userLoginDTO.getDeviceName() == null || userLoginDTO.getDeviceType() == null){
throw new RuntimeException("未获取到设备");
}
// 查询用户 // 查询用户
User user = userMapper.getByUsername(userLoginDTO.getUsername()); User user = userMapper.getByUsername(userLoginDTO.getUsername());
if (user == null) { if (user == null) {
@@ -92,22 +99,45 @@ public class UserServiceImpl implements UserService {
if (!match) { if (!match) {
throw new RuntimeException("密码错误"); throw new RuntimeException("密码错误");
} }
// 用户封禁验证
userBanValidator.validateUserNotBanned(user.getUserId());
// 生成token // 生成token
String token = JwtUtil.generateToken(String.valueOf(user.getUserId())); 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( redisTemplate.opsForValue().set(
redisKey, tokenKey,
token, token,
7, 7,
TimeUnit.DAYS 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 userLoginVO = new UserLoginVO();
userLoginVO.setUserId(user.getUserId()); userLoginVO.setUserId(userId);
userLoginVO.setNickname(user.getNickname()); userLoginVO.setNickname(user.getNickname());
userLoginVO.setToken(token); userLoginVO.setToken(token);
userLoginVO.setDeviceId(deviceId);
return userLoginVO; return userLoginVO;
} }
@@ -116,8 +146,11 @@ public class UserServiceImpl implements UserService {
* @param token 登录凭证 * @param token 登录凭证
*/ */
@Override @Override
public void logout(String token) { public void logout(String token, String deviceId) {
Claims claims = JwtUtil.getClaimsFromToken(token); Claims claims = JwtUtil.getClaimsFromToken(token);
// 获取token信息
String userId = claims.getSubject();
// 获取token的过期时间
Date expiration = claims.getExpiration(); Date expiration = claims.getExpiration();
// 判断 token 是否已过期 // 判断 token 是否已过期
long ttl = expiration.getTime() - System.currentTimeMillis(); long ttl = expiration.getTime() - System.currentTimeMillis();
@@ -126,6 +159,15 @@ public class UserServiceImpl implements UserService {
return; return;
} }
// 从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; String logoutKey = "jwt:blacklist:" + token;
redisTemplate.opsForValue().set( redisTemplate.opsForValue().set(
logoutKey, logoutKey,
@@ -485,4 +527,28 @@ public class UserServiceImpl implements UserService {
} }
return result; return result;
} }
}
/**
* 判断用户是否在线
* @param userId 用户ID
* @return true: 在线false: 离线
*/
@Override
public boolean isUserOnline(Long userId) {
if (userId == null) {
return false;
}
// 1. 是否被封禁
String banKey = "user:ban:" + userId;
if (Boolean.TRUE.equals(redisTemplate.hasKey(banKey))) {
return false;
}
// 2. 是否存在登录 token
String tokenKey = "login:token:" + userId;
Boolean online = redisTemplate.hasKey(tokenKey);
return Boolean.TRUE.equals(online);
}
}

View File

@@ -0,0 +1,26 @@
package com.bao.dating.task;
import com.bao.dating.mapper.UserBanMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@EnableScheduling
public class UserBanScheduleTask {
@Autowired
private UserBanMapper userBanMapper;
/**
* 每天凌晨 3 点同步过期封禁
*/
@Scheduled(cron = "0 0 3 * * ?")
public void syncExpiredUserBan() {
int rows = userBanMapper.updateExpiredBans();
log.info("封禁同步任务执行完成,解封 {} 个用户", rows);
}
}

View File

@@ -0,0 +1,65 @@
package com.bao.dating.util;
import com.bao.dating.context.UserContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* 用户封禁验证工具类
* 提供统一的用户封禁状态检查功能
*
* @author KilLze
*/
@Component
public class UserBanUtil {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 验证指定用户是否被封禁
*
* @param userId 用户ID
* @throws RuntimeException 如果用户被封禁则抛出异常
*/
public void validateUserNotBanned(Long userId) {
String banKey = "user:ban:" + userId;
if (Boolean.TRUE.equals(redisTemplate.hasKey(banKey))) {
String reason = (String) redisTemplate.opsForValue().get(banKey);
// 获取剩余过期时间(秒)
Long ttlSeconds = redisTemplate.getExpire(banKey, TimeUnit.SECONDS);
String remainingTime = "";
if (ttlSeconds != null && ttlSeconds > 0) {
long days = ttlSeconds / (24 * 3600);
long hours = (ttlSeconds % (24 * 3600)) / 3600;
long minutes = (ttlSeconds % 3600) / 60;
if (days > 0) {
remainingTime = ",剩余时间:" + days + "" + hours + "小时";
} else if (hours > 0) {
remainingTime = ",剩余时间:" + hours + "小时" + minutes + "分钟";
} else {
remainingTime = ",剩余时间:" + minutes + "分钟";
}
} else {
remainingTime = ",永久封禁";
}
throw new RuntimeException("账号已被封禁,原因:" + reason + remainingTime);
}
}
/**
* 验证当前登录用户是否被封禁
*
* @throws RuntimeException 如果用户被封禁则抛出异常
*/
public void validateCurrentUserNotBanned() {
Long userId = UserContext.getUserId();
validateUserNotBanned(userId);
}
}

View File

@@ -0,0 +1,64 @@
package com.bao.dating.util;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.bao.dating.config.AliyunOSSConfig;
import com.bao.dating.mapper.PostMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.InputStream;
@Component
public class WatermarkUtil {
@Autowired
private AliyunOSSConfig aliyunOSSConfig;
public BufferedImage downloadAndWatermark(String objectKey, String username, Long userId) throws Exception {
OSS ossClient = new OSSClientBuilder().build(
aliyunOSSConfig.getEndpoint(),
aliyunOSSConfig.getAccessKeyId(),
aliyunOSSConfig.getAccessKeySecret()
);
InputStream inputStream = ossClient.getObject(aliyunOSSConfig.getBucketName(), objectKey).getObjectContent();
BufferedImage image = ImageIO.read(inputStream);
Graphics2D g2d = image.createGraphics();
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
// 字体小一点
Font font = new Font("微软雅黑", Font.BOLD, 24);
g2d.setFont(font);
String text = "作者:" + username + " (ID:" + userId + ")";
FontMetrics fm = g2d.getFontMetrics();
int textWidth = fm.stringWidth(text);
int textHeight = fm.getHeight();
// 右下角留边距
int x = image.getWidth() - textWidth - 20;
int y = image.getHeight() - 20;
// 黑色描边
g2d.setColor(Color.BLACK);
g2d.drawString(text, x - 1, y - 1);
g2d.drawString(text, x + 1, y - 1);
g2d.drawString(text, x - 1, y + 1);
g2d.drawString(text, x + 1, y + 1);
// 白色正文
g2d.setColor(Color.WHITE);
g2d.drawString(text, x, y);
g2d.dispose();
ossClient.shutdown();
return image;
}
}

View File

@@ -135,5 +135,11 @@
<select id="selectFavoriteCount" resultType="java.lang.Integer"> <select id="selectFavoriteCount" resultType="java.lang.Integer">
select dating.post.favorite_count from dating.post where post.post_id = #{postId} select dating.post.favorite_count from dating.post where post.post_id = #{postId}
</select> </select>
<select id="getUsernameByUserId" resultType="map">
SELECT u.user_name, u.user_id, p.media_oss_keys
FROM post p
LEFT JOIN user u ON p.user_id = u.user_id
WHERE p.post_id = #{postId}
</select>
</mapper> </mapper>

View File

@@ -0,0 +1,50 @@
<?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.UserBanMapper">
<!-- 向数据库中添加用户封禁信息 -->
<insert id="insertBan" useGeneratedKeys="true" keyProperty="id">
INSERT INTO user_ban
(user_id, reason, ban_start_time, ban_end_time, status)
VALUES
(#{userId}, #{reason}, #{banStartTime}, #{banEndTime}, #{status})
</insert>
<!-- 查询指定用户是否存在未过期的封禁信息 -->
<select id="existsActiveBan" resultType="int">
SELECT COUNT(1)
FROM user_ban
WHERE user_id = #{userId}
AND status = 1
LIMIT 1
</select>
<!-- 查询指定用户是否存在未过期的封禁信息 -->
<select id="selectActiveBan" resultType="com.bao.dating.pojo.entity.UserBan">
SELECT *
FROM user_ban
WHERE user_id = #{userId}
AND status = 1
LIMIT 1
</select>
<!-- 解封指定用户 -->
<update id="unbanUser">
UPDATE user_ban
SET status = 0
WHERE user_id = #{userId}
AND status = 1
</update>
<!-- 批量更新已过期的封禁信息 -->
<update id="updateExpiredBans">
UPDATE user_ban
SET status = 0
WHERE status = 1
AND ban_end_time IS NOT NULL
AND ban_end_time &lt; NOW()
</update>
</mapper>

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

@@ -111,9 +111,9 @@
<result property="latitude" column="user_latitude"/> <result property="latitude" column="user_latitude"/>
<result property="longitude" column="user_longitude"/> <result property="longitude" column="user_longitude"/>
</resultMap> </resultMap>
<select id="findByLatLngRange" resultMap="UserInfoVOResultMap"> <select id="findByLatLngRange" resultMap="UserInfoVOResultMap">
SELECT SELECT
user_id, user_id,
user_name, user_name,
nickname, nickname,

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='用户登录设备记录表';