diff --git a/README.en.md b/README.en.md new file mode 100644 index 00000000..e1c82325 --- /dev/null +++ b/README.en.md @@ -0,0 +1,361 @@ +
+ + + +# youlai-boot youlai-boot + + + +**Enterprise-grade permission management backend based on Spring Boot** + + + +[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-4.0.5-6DB33F?logo=spring-boot)](https://spring.io/projects/spring-boot) + +[![JDK](https://img.shields.io/badge/JDK-17%2B-007396?logo=openjdk)](https://openjdk.org/) + +[![License](https://img.shields.io/badge/License-Apache%202.0-blue?logo=apache)](LICENSE) + +[![Gitee Star](https://gitee.com/youlaiorg/youlai-boot/badge/star.svg)](https://gitee.com/youlaiorg/youlai-boot/stargazers) + +[![GitHub Star](https://img.shields.io/github/stars/youlaitech/youlai-boot?style=social)](https://github.com/youlaitech/youlai-boot) + +[![GitCode Star](https://gitcode.com/youlai/youlai-boot/star/badge.svg)](https://gitcode.com/youlai/youlai-boot/stargazers) + + + +
+ + + +![](./docs/images/rainbow.png "rainbow.png") + + + +
+ + + +[![Live Preview](https://img.shields.io/badge/Live%20Preview-2D8CF0?style=for-the-badge&logo=google-chrome&logoColor=white)](https://vue.youlai.tech) + +[![Mobile Preview](https://img.shields.io/badge/Mobile%20Preview-19BE6B?style=for-the-badge&logo=android&logoColor=white)](https://app.youlai.tech) + +[![Documentation](https://img.shields.io/badge/Documentation-8B5CF6?style=for-the-badge&logo=gitbook&logoColor=white)](https://www.youlai.tech/docs/server/spring-boot/) + +[![简体中文](https://img.shields.io/badge/简体中文-00B4D8?style=for-the-badge&logo=google-translate&logoColor=white)](./README.md) + + + +
+ + + +## Introduction + + + +**youlai-boot** is an enterprise-grade permission management backend built on Spring Boot. It ships with the frontend [vue3-element-admin](https://gitee.com/youlaiorg/vue3-element-admin) and the mobile app [youlai-app](https://gitee.com/youlaiorg/youlai-app), and is one of **7 language implementations** (Java / Node.js / Go / Python / PHP / C# / Rust) that share the same API specification and database schema. It is suitable for learning, reference, and secondary development of enterprise admin systems. + + + +## Core Features + + + +- 🔐 **Security** — Spring Security + JWT/Redis dual-session model, token renewal, multi-device mutual exclusion + +- 🛡️ **Fine-grained permissions** — 5-level RBAC: data → menu → button → API → field + +- ⚡ **Code generator** — one-click generation of full-stack CRUD code + +- 📦 **Complete modules** — users, roles, menus, departments, dictionaries, files, scheduled tasks, message center, operation logs + +- 🌐 **Multi-tenant SaaS** — data isolation + tenant config, with a standalone [youlai-boot-tenant](https://gitee.com/youlaiorg/youlai-boot-tenant) edition + +- 🔌 **Real-time communication** — SSE push: online user count, dictionary sync, notification broadcast + + + +## System Preview + + + +**PC** + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PC Preview 1PC Preview 2
PC Preview 3PC Preview 4
PC Preview 5PC Preview 6
+ + + +**Mobile** + + + + + + + + + + + + + + + + + +
App Preview 1App Preview 2App Preview 3App Preview 4
+ + + +## Quick Start + + + +**Requirements**: JDK 17+ · MySQL 8.0+ · Redis 6.0+ + + + +1. Clone: `git clone https://gitee.com/youlaiorg/youlai-boot.git` + +2. Import database: `sql/youlai-admin.sql` + +3. Adjust config (optional, a read-only online data source is configured by default): `src/main/resources/application-dev.yml` + +4. Start and visit http://localhost:8000/doc.html + + + +Default credentials: `admin` / `123456` + + + +Detailed guide: https://www.youlai.tech/docs/server/spring-boot/quick-start.html + + + +## Tech Stack + + + +| Tech | Version | Description | + +|:-----|:--------|:------------| + +| Spring Boot | 4.0.5 | Core framework | + +| Spring Security | 6.x | Auth & authorization | + +| MyBatis-Plus | 3.5.15 | ORM | + +| Druid | 1.2.24 | Connection pool | + +| Redis + Redisson | 6.0+ / 4.1.0 | Cache · Session · Distributed lock | + +| Caffeine | 2.9.3 | Local cache | + +| XXL-Job | 3.2.0 | Distributed scheduled tasks | + +| Knife4j | 4.5.0 | API docs | + +| MapStruct | 1.6.3 | Object mapping | + +| MinIO | 8.5.10 | Object storage | + + + +## Directory Structure + + + +``` + +youlai-boot/ + +├── deploy/ + +│ └── docker/ # Docker orchestration + +├── docs/ # Docs and image assets + +├── sql/ # Database init scripts + +├── src/ # Source code + +│ ├── YouLaiBootApplication.java # Bootstrap class + +│ ├── auth/ # Auth (login/logout/token) + +│ ├── codegen/ # Code generator + +│ ├── common/ # Common module (constants/enums/response) + +│ ├── file/ # File service (MinIO/local/OSS) + +│ ├── framework/ # Technical framework layer + +│ │ ├── apidoc/ # OpenAPI / Knife4j + +│ │ ├── cache/ # Redis / Caffeine cache + +│ │ ├── captcha/ # Graphic captcha + +│ │ ├── integration/ # SMS / Email / WeChat + +│ │ ├── job/ # XXL-Job scheduled tasks + +│ │ ├── mybatis/ # MyBatis-Plus config + +│ │ ├── security/ # Security / JWT / Token + +│ │ └── web/ # Global exception / CORS / rate limit + +│ ├── message/ # SSE push + +│ └── system/ # Business (user/role/menu/dept) + +└── pom.xml # Maven dependency management + +``` + + + +## Ecosystem + + + +**Frontend** + + + +| Project | Stack | Description | + +|:-----|:------|:------------| + +| [vue3-element-admin](https://gitee.com/youlaiorg/vue3-element-admin) | Vue 3 + Element Plus | PC admin frontend (recommended) | + +| [youlai-app](https://gitee.com/youlaiorg/youlai-app) | Vue 3 + UniApp | Mobile App | + + + +**Backend** + + + +| Project | Stack | Description | +| [youlai-boot](https://gitee.com/youlaiorg/youlai-boot) | Spring Boot + MyBatis-Plus | Java (recommended) | +| [youlai-nest](https://gitee.com/youlaiorg/youlai-nest) | NestJS + TypeORM | Node.js | +| [youlai-gin](https://gitee.com/youlaiorg/youlai-gin) | Go + Gorm | Go | +| [youlai-django](https://gitee.com/youlaiorg/youlai-django) | Django + DRF | Python | +| [youlai-fastapi](https://gitee.com/youlaiorg/youlai-fastapi) | FastAPI + SQLAlchemy | Python | +| [youlai-think](https://gitee.com/youlaiorg/youlai-think) | ThinkPHP + ThinkORM | PHP | +| [youlai-aspnet](https://gitee.com/youlaiorg/youlai-aspnet) | ASP.NET Core + EF Core | C# | +| [youlai-axum](https://gitee.com/youlaiorg/youlai-axum) | Axum + SeaORM | Rust | +> **youlai-boot** also provides the following variants and branches: [Multi-Tenant](https://gitee.com/youlaiorg/youlai-boot-tenant) · [MyBatis-Flex](https://gitee.com/youlaiorg/youlai-boot-flex) · [Spring Boot 3](https://gitee.com/youlaiorg/youlai-boot/tree/spring-boot-3) · [PostgreSQL](https://gitee.com/youlaiorg/youlai-boot/tree/db-pg) · [Multi-Module](https://gitee.com/youlaiorg/youlai-boot/tree/multi-module) + +> + +> The eight backends share the same **RESTful API specification** and **database schema**, so the frontend can switch seamlessly. + + + +## Documentation + + + +| Resource | Link | + +|:-----|:-----| + +| 📖 Full docs site | [www.youlai.tech](https://www.youlai.tech/) | + +| 🖥️ PC live preview | [vue.youlai.tech](https://vue.youlai.tech) | + +| 📱 Mobile live preview | [app.youlai.tech](https://app.youlai.tech) | + +| 🔗 Apifox API docs | [apifox.com](https://www.apifox.cn/apidoc/shared-195e783f-4d85-4235-a038-eec696de4ea5) | + +| 🔗 Local API docs | [localhost:8000/doc.html](http://localhost:8000/doc.html) | + + + +## License + + + +Released under the [Apache License 2.0](LICENSE); free for commercial use. + + + +--- + + + + + + + + + + + + + + + + + + + +
+ + Official WeChat Account
+ + Official WeChat Account + +
     + + Mini Program
+ + Mini Program + +
     + + Add author on WeChat
+ + Add author on WeChat + +
+ + + +

Technical discussion · Feedback · Business cooperation

+ diff --git a/README.md b/README.md index 1d498c4c..e0d97ef7 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@
-youlai-boot +# youlai-boot youlai-boot -# youlai-boot - -**Spring Boot 4 企业级权限管理系统后端** +**Spring Boot 企业级权限管理系统后端** [![Spring Boot](https://img.shields.io/badge/Spring%20Boot-4.0.5-6DB33F?logo=spring-boot)](https://spring.io/projects/spring-boot) [![JDK](https://img.shields.io/badge/JDK-17%2B-007396?logo=openjdk)](https://openjdk.org/) @@ -15,17 +13,20 @@
-![](https://foruda.gitee.com/images/1708618984641188532/a7cca095_716974.png "rainbow.png") +

rainbow

-[🖥️ 在线预览](https://vue.youlai.tech) | [📲 移动端预览](https://app.youlai.tech) | [📖 文档](https://www.youlai.tech/docs/server/spring-boot/) +[![在线预览](https://img.shields.io/badge/在线预览-2D8CF0?style=for-the-badge&logo=google-chrome&logoColor=white)](https://vue.youlai.tech) +[![移动端预览](https://img.shields.io/badge/移动端预览-19BE6B?style=for-the-badge&logo=android&logoColor=white)](https://app.youlai.tech) +[![阅读文档](https://img.shields.io/badge/阅读文档-8B5CF6?style=for-the-badge&logo=gitbook&logoColor=white)](https://www.youlai.tech/docs/server/spring-boot/) +[![English](https://img.shields.io/badge/English-00B4D8?style=for-the-badge&logo=google-translate&logoColor=white)](./README.en.md)
-## 简介 +## 项目简介 -**youlai-boot** 是一套基于 Spring Boot 4 的企业级权限管理系统后端,配套前端 [vue3-element-admin](https://gitee.com/youlaiorg/vue3-element-admin) 和移动端 [youlai-app](https://gitee.com/youlaiorg/youlai-app),并提供 **6 种语言实现**(Java / Node.js / Go / Python / PHP / C#),共享同一套 API 规范与数据库结构。适用于企业中后台管理系统的学习参考与二次开发。 +**youlai-boot** 是一套基于 Spring Boot 的企业级权限管理系统后端,配套前端 [vue3-element-admin](https://gitee.com/youlaiorg/vue3-element-admin) 和移动端 [youlai-app](https://gitee.com/youlaiorg/youlai-app),并提供 **7 种语言实现**(Java / Node.js / Go / Python / PHP / C# / Rust),共享同一套 API 规范与数据库结构。适用于企业中后台管理系统的学习参考与二次开发。 ## 核心特性 @@ -36,12 +37,6 @@ - 🌐 **多租户 SaaS** — 数据隔离 + 租户配置,独立 [youlai-boot-tenant](https://gitee.com/youlaiorg/youlai-boot-tenant) 版本 - 🔌 **实时通信** — SSE 推送:在线用户数、字典同步、通知广播 -## 技术架构 - -

- youlai-boot 技术架构图 -

- ## 系统预览 **PC 端** @@ -76,31 +71,37 @@ **环境要求**:JDK 17+ · MySQL 8.0+ · Redis 6.0+ -1. 克隆项目:`git clone https://gitee.com/youlaiorg/youlai-boot.git` -2. 导入数据库:`sql/youlai-admin.sql` -3. 修改配置(可选,默认已配置线上只读数据源):`src/main/resources/application-dev.yml` -4. 启动服务,访问 http://localhost:8000/doc.html +1. 克隆项目: + ```bash + git clone https://gitee.com/youlaiorg/youlai-boot.git + ``` -默认账号:`admin` / `123456` +2. 导入数据库: + ```bash + sql/youlai-admin.sql + ``` -**Docker 部署**:`cd deploy/docker`,然后 `docker-compose up -d` +3. 修改配置(可选,默认已配置线上只读数据源): + ```bash + src/main/resources/application-dev.yml + ``` -详细指南:[部署文档](https://www.youlai.tech/docs/server/spring-boot/deploy) · [开发规范](https://www.youlai.tech/docs/server/spring-boot/dev-standards) +4. 启动服务: -## 技术栈 + **方式一:IDEA 启动(推荐)** + 用 IDEA 打开项目,等待 Maven 依赖下载完成,运行启动类 `YouLaiBootApplication` 即可。 -| 技术 | 版本 | 说明 | -|:-----|:-----|:-----| -| Spring Boot | 4.0.5 | 核心框架 | -| Spring Security | 6.x | 认证授权 | -| MyBatis-Plus | 3.5.15 | ORM 框架 | -| Druid | 1.2.24 | 数据库连接池 | -| Redis + Redisson | 6.0+ / 4.1.0 | 缓存 · 会话 · 分布式锁 | -| Caffeine | 2.9.3 | 本地缓存 | -| XXL-Job | 3.2.0 | 分布式定时任务 | -| Knife4j | 4.5.0 | API 文档 | -| MapStruct | 1.6.3 | 对象映射 | -| MinIO | 8.5.10 | 对象存储 | + **方式二:命令行启动** + ```bash + mvn spring-boot:run + ``` + + 启动后访问 [http://localhost:8000/doc.html](http://localhost:8000/doc.html),能打开接口文档即说明后端已正常运行。 + +5. 启动前端(可选): + 如需可视化操作界面,启动配套前端 [vue3-element-admin](https://gitee.com/youlaiorg/vue3-element-admin),访问 [http://localhost:3000](http://localhost:3000),使用 `admin` / `123456` 登录。 + +> 更多内容详见官方文档:[快速开始](https://www.youlai.tech/docs/server/spring-boot/quick-start.html) · [部署指南](https://www.youlai.tech/docs/server/spring-boot/deploy.html) ## 目录结构 @@ -110,7 +111,7 @@ youlai-boot/ │ └── docker/ # Docker 部署编排 ├── docs/ # 项目文档与图片资源 ├── sql/ # 数据库初始化脚本 -├── src/main/java/com/youlai/boot/ +├── src/ # 源码目录 │ ├── YouLaiBootApplication.java # 启动类 │ ├── auth/ # 认证授权(登录/登出/令牌) │ ├── codegen/ # 代码生成器 @@ -134,61 +135,58 @@ youlai-boot/ **前端** -| 项目 | 技术栈 | 说明 | -|:-----|:-------|:-----| -| [vue3-element-admin](https://gitee.com/youlaiorg/vue3-element-admin) | Vue 3 + Element Plus | PC 管理前端(主推) | -| [youlai-app](https://gitee.com/youlaiorg/youlai-app) | Vue 3 + UniApp | 移动端 App | +| 项目 | 技术栈 | 说明 | 更新状态 | +|:-----|:-------|:-----|:---------| +| [vue3-element-admin](https://gitee.com/youlaiorg/vue3-element-admin) | Vue 3 + Element Plus | PC 管理前端(主推) | ✅️ | +| [youlai-app](https://gitee.com/youlaiorg/youlai-app) | Vue 3 + UniApp | 移动端 App | ✅️ | **后端** -| 项目 | 技术栈 | 说明 | -|:-----|:-------|:-----| -| [youlai-nest](https://gitee.com/youlaiorg/youlai-nest) | NestJS + TypeORM | Node.js | -| [youlai-gin](https://gitee.com/youlaiorg/youlai-gin) | Go + Gorm | Go | -| [youlai-django](https://gitee.com/youlaiorg/youlai-django) | Django + DRF | Python | -| [youlai-thinkphp](https://gitee.com/youlaiorg/youlai-thinkphp) | ThinkPHP 8 | PHP | -| [youlai-aspnet](https://gitee.com/youlaiorg/youlai-aspnet) | ASP.NET Core | C# | +| 项目 | 技术栈 | 说明 | 更新状态 | +|:-----|:-------|:-----|:---------| +| [youlai-boot](https://gitee.com/youlaiorg/youlai-boot) | Spring Boot + MyBatis-Plus | Java(主推) | ✅️ | +| [youlai-nest](https://gitee.com/youlaiorg/youlai-nest) | NestJS + TypeORM | Node.js | ✅️ | +| [youlai-gin](https://gitee.com/youlaiorg/youlai-gin) | Go + Gorm | Go | ✅️ | +| [youlai-django](https://gitee.com/youlaiorg/youlai-django) | Django + DRF | Python | ✅️ | +| [youlai-fastapi](https://gitee.com/youlaiorg/youlai-fastapi) | FastAPI + SQLAlchemy | Python | ✅️ | +| [youlai-laravel](https://gitee.com/youlaiorg/youlai-laravel) | Laravel + Eloquent | PHP | ✅️ | +| [youlai-think](https://gitee.com/youlaiorg/youlai-think) | ThinkPHP + ThinkORM | PHP | ✅️ | +| [youlai-aspnet](https://gitee.com/youlaiorg/youlai-aspnet) | ASP.NET Core + EF Core | C# | ✅️ | +| [youlai-axum](https://gitee.com/youlaiorg/youlai-axum) | Axum + SeaORM | Rust | ✅️ | -> **youlai-boot** 还提供以下变种和分支版本:[多租户](https://gitee.com/youlaiorg/youlai-boot-tenant)(Spring Boot 4)· [MyBatis-Flex](https://gitee.com/youlaiorg/youlai-boot-flex)(Spring Boot 4)· [Spring Boot 3](https://gitee.com/youlaiorg/youlai-boot/tree/spring-boot-3) · [PostgreSQL](https://gitee.com/youlaiorg/youlai-boot/tree/db-pg) · [多模块](https://gitee.com/youlaiorg/youlai-boot/tree/multi-module) -> -> 六种后端共享同一套 **RESTful API 规范** 和 **数据库结构**,前端可无缝切换。 +> 九种后端共享同一套 **RESTful API 规范** 和 **数据库结构**,前端可无缝切换。 -## 文档资源 +**变种与衍生版本** -| 资源 | 地址 | -|:-----|:-----| -| 📖 完整文档站 | [www.youlai.tech](https://www.youlai.tech/) | -| 🖥️ PC 端在线预览 | [vue.youlai.tech](https://vue.youlai.tech) | -| 📱 移动端在线预览 | [app.youlai.tech](https://app.youlai.tech) | -| 🔗 Apifox 接口文档 | [apifox.com](https://www.apifox.cn/apidoc/shared-195e783f-4d85-4235-a038-eec696de4ea5) | -| 🔗 本地接口文档 | [localhost:8000/doc.html](http://localhost:8000/doc.html) | +| 项目 | 基础 | 说明 | 更新状态 | +|:-----|:-----|:-----|:---------| +| [youlai-boot-tenant](https://gitee.com/youlaiorg/youlai-boot-tenant) | youlai-boot | 多租户 SaaS,租户隔离与租户配置 | ✅️ | +| [youlai-boot-flex](https://gitee.com/youlaiorg/youlai-boot-flex) | youlai-boot | 改用 MyBatis-Flex | ✅️ | +| [youlai-boot (db-pg)](https://gitee.com/youlaiorg/youlai-boot/tree/db-pg) | youlai-boot | PostgreSQL 数据库分支 | ✅️ | +| [youlai-boot (multi-module)](https://gitee.com/youlaiorg/youlai-boot/tree/multi-module) | youlai-boot | 多模块工程拆分 | ✅️ | +| [youlai-boot (spring-boot-3)](https://gitee.com/youlaiorg/youlai-boot/tree/spring-boot-3) | youlai-boot | Spring Boot 3 兼容分支 | ✅️ | +| [youlai-nest (multi-tenant)](https://gitee.com/youlaiorg/youlai-nest/tree/multi-tenant) | youlai-nest | 多租户 SaaS,租户隔离与租户配置 | ✅️ | -## 参与贡献 +## 技术合作 -欢迎提交 Issue 和 Pull Request!详见 [贡献指南](https://www.youlai.tech/faq/help)。 +本项目采用 [Apache License 2.0](LICENSE) 开源,可免费商用。欢迎在 [Issue](https://gitee.com/youlaiorg/youlai-boot/issues) 提交问题或反馈,也欢迎提交 [Pull Request](https://gitee.com/youlaiorg/youlai-boot/pulls) 共建。 -[![Contributors](https://contrib.rocks/image?repo=haoxianrui/youlai-boot)](https://github.com/haoxianrui/youlai-boot/graphs/contributors) - -## 开源协议 - -本项目基于 [Apache License 2.0](LICENSE) 开源,可免费用于商业项目。 - ---- +如需技术支持、商务合作、二次开发、项目定制或私有化部署,可联系作者微信(见下方二维码)。 diff --git a/docs/images/architecture/architecture.png b/docs/images/architecture/architecture.png deleted file mode 100644 index fbd6a09f..00000000 Binary files a/docs/images/architecture/architecture.png and /dev/null differ diff --git a/docs/images/logo/favicon.ico b/docs/images/logo/favicon.ico new file mode 100644 index 00000000..0a6744a4 Binary files /dev/null and b/docs/images/logo/favicon.ico differ diff --git a/docs/images/logo/logo.png b/docs/images/logo/logo.png index 5a0435f3..ebfa2af6 100644 Binary files a/docs/images/logo/logo.png and b/docs/images/logo/logo.png differ diff --git a/docs/images/qr/wechat-app.jpg b/docs/images/qr/wechat-app.jpg deleted file mode 100644 index 73a907b2..00000000 Binary files a/docs/images/qr/wechat-app.jpg and /dev/null differ diff --git a/docs/images/qr/wechat-offical.png b/docs/images/qr/wechat-offical.png deleted file mode 100644 index 406447c8..00000000 Binary files a/docs/images/qr/wechat-offical.png and /dev/null differ diff --git a/docs/images/qrcode/wechat-mp.jpg b/docs/images/qrcode/wechat-mp.jpg new file mode 100644 index 00000000..34852141 Binary files /dev/null and b/docs/images/qrcode/wechat-mp.jpg differ diff --git a/docs/images/qrcode/wechat-official.jpg b/docs/images/qrcode/wechat-official.jpg new file mode 100644 index 00000000..576f210a Binary files /dev/null and b/docs/images/qrcode/wechat-official.jpg differ diff --git a/docs/images/qr/wechat-personal.png b/docs/images/qrcode/wechat-personal.png similarity index 100% rename from docs/images/qr/wechat-personal.png rename to docs/images/qrcode/wechat-personal.png diff --git a/docs/images/rainbow.png b/docs/images/rainbow.png new file mode 100644 index 00000000..4ab217c9 Binary files /dev/null and b/docs/images/rainbow.png differ diff --git a/pom.xml b/pom.xml index da445d70..23e5b42a 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.youlaiyoulai-boot - 4.3.2 + 4.5.0基于 Java 17 + SpringBoot 4 + Spring Security 构建的权限管理系统。 @@ -39,7 +39,7 @@ 8.5.10 - 4.8.1 + 4.12.0 3.16.3 @@ -47,7 +47,7 @@ 4.1.0 - 3.5.6 + 3.5.15 2.3 @@ -57,7 +57,7 @@ 4.7.6 2.2.1 - 2.9.3 + 3.2.0 2.14.5 diff --git a/sql/youlai-admin.sql b/sql/youlai-admin.sql index 84ca0a63..37e3caf6 100644 --- a/sql/youlai-admin.sql +++ b/sql/youlai-admin.sql @@ -221,7 +221,7 @@ INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_n -- 组件封装 INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (701, 6, '0,6', '富文本编辑器', 'M', 'WangEditor', 'wang-editor', 'demo/wang-editor', NULL, NULL, 1, 1, 2, '', '', now(), now(), NULL); INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (702, 6, '0,6', '图片上传', 'M', 'Upload', 'upload', 'demo/upload', NULL, NULL, 1, 1, 3, '', '', now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (703, 6, '0,6', '图标选择器', 'M', 'IconSelect', 'icon-select', 'demo/icon-select', NULL, NULL, 1, 1, 4, '', '', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (703, 6, '0,6', '图标选择器', 'M', 'DemoIconSelect', 'icon-select', 'demo/icon-select', NULL, NULL, 1, 1, 4, '', '', now(), now(), NULL); INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (704, 6, '0,6', '字典组件', 'M', 'DictDemo', 'dict-demo', 'demo/dictionary', NULL, NULL, 1, 1, 4, '', '', now(), now(), NULL); INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (705, 6, '0,6', '增删改查', 'M', 'Curd', 'curd', 'demo/curd/index', NULL, NULL, 1, 1, 0, '', '', now(), now(), NULL); INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (706, 6, '0,6', '列表选择器', 'M', 'TableSelect', 'table-select', 'demo/table-select/index', NULL, NULL, 1, 1, 1, '', '', now(), now(), NULL); diff --git a/src/main/java/com/youlai/boot/app/controller/AppAuthController.java b/src/main/java/com/youlai/boot/app/controller/AppAuthController.java index 317a8aa0..490eac92 100644 --- a/src/main/java/com/youlai/boot/app/controller/AppAuthController.java +++ b/src/main/java/com/youlai/boot/app/controller/AppAuthController.java @@ -3,7 +3,7 @@ package com.youlai.boot.app.controller; import com.youlai.boot.app.model.req.MobileLoginReq; import com.youlai.boot.app.model.req.MobileRegisterReq; import com.youlai.boot.app.service.AppAuthService; -import com.youlai.boot.common.annotation.Log; +import com.youlai.boot.framework.annotation.Log; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; import com.youlai.boot.common.result.Result; diff --git a/src/main/java/com/youlai/boot/app/mapper/AppUserMapper.java b/src/main/java/com/youlai/boot/app/mapper/AppUserMapper.java index 64ee286e..a0c6d0a0 100644 --- a/src/main/java/com/youlai/boot/app/mapper/AppUserMapper.java +++ b/src/main/java/com/youlai/boot/app/mapper/AppUserMapper.java @@ -4,8 +4,8 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.youlai.boot.app.model.entity.AppUser; import com.youlai.boot.app.model.form.AppUserForm; -import com.youlai.boot.common.annotation.DataPermission; -import com.youlai.boot.framework.security.model.UserAuthInfo; +import com.youlai.boot.framework.annotation.DataPermission; +import com.youlai.boot.framework.security.model.SecurityUser; import com.youlai.boot.system.model.query.UserQuery; import com.youlai.boot.system.model.vo.UserExportVO; import com.youlai.boot.system.model.vo.UserPageVO; @@ -46,9 +46,9 @@ public interface AppUserMapper extends BaseMapper { * @param username 用户名 * @return 认证信息 */ - UserAuthInfo getAuthInfoByUsername(String username); + SecurityUser getAuthInfoByUsername(String username); - default UserAuthInfo getAuthCredentialsByUsername(String username) { + default SecurityUser getAuthCredentialsByUsername(String username) { return getAuthInfoByUsername(username); } @@ -58,9 +58,9 @@ public interface AppUserMapper extends BaseMapper { * @param mobile 手机号 * @return 认证信息 */ - UserAuthInfo getAuthInfoByMobile(String mobile); + SecurityUser getAuthInfoByMobile(String mobile); - default UserAuthInfo getAuthCredentialsByMobile(String mobile) { + default SecurityUser getAuthCredentialsByMobile(String mobile) { return getAuthInfoByMobile(mobile); } diff --git a/src/main/java/com/youlai/boot/app/model/entity/AppUser.java b/src/main/java/com/youlai/boot/app/model/entity/AppUser.java index 88c6ebaa..d5cc1672 100644 --- a/src/main/java/com/youlai/boot/app/model/entity/AppUser.java +++ b/src/main/java/com/youlai/boot/app/model/entity/AppUser.java @@ -1,7 +1,7 @@ package com.youlai.boot.app.model.entity; import com.baomidou.mybatisplus.annotation.TableName; -import com.youlai.boot.common.base.BaseEntity; +import com.youlai.boot.framework.base.BaseEntity; import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/com/youlai/boot/app/service/AppUserService.java b/src/main/java/com/youlai/boot/app/service/AppUserService.java index 620bfe85..f56853e4 100644 --- a/src/main/java/com/youlai/boot/app/service/AppUserService.java +++ b/src/main/java/com/youlai/boot/app/service/AppUserService.java @@ -5,7 +5,7 @@ import com.baomidou.mybatisplus.extension.service.IService; import com.youlai.boot.app.model.entity.AppUser; import com.youlai.boot.app.model.form.AppUserForm; import com.youlai.boot.common.model.Option; -import com.youlai.boot.framework.security.model.UserAuthInfo; +import com.youlai.boot.framework.security.model.SecurityUser; import com.youlai.boot.system.model.entity.SysUser; import com.youlai.boot.system.model.form.*; import com.youlai.boot.system.model.query.UserQuery; @@ -71,11 +71,11 @@ public interface AppUserService extends IService { * 根据用户名获取认证信息 * * @param username 用户名 - * @return {@link UserAuthInfo} + * @return {@link SecurityUser} */ - UserAuthInfo getAuthInfoByUsername(String username); + SecurityUser getAuthInfoByUsername(String username); - default UserAuthInfo getAuthCredentialsByUsername(String username) { + default SecurityUser getAuthCredentialsByUsername(String username) { return getAuthInfoByUsername(username); } @@ -187,11 +187,11 @@ public interface AppUserService extends IService { * 根据手机号获取用户认证信息 * * @param mobile 手机号 - * @return {@link UserAuthInfo} + * @return {@link SecurityUser} */ - UserAuthInfo getAuthInfoByMobile(String mobile); + SecurityUser getAuthInfoByMobile(String mobile); - default UserAuthInfo getAuthCredentialsByMobile(String mobile) { + default SecurityUser getAuthCredentialsByMobile(String mobile) { return getAuthInfoByMobile(mobile); } diff --git a/src/main/java/com/youlai/boot/app/service/impl/AppUserServiceImpl.java b/src/main/java/com/youlai/boot/app/service/impl/AppUserServiceImpl.java index 1c02ecbc..962d0d1a 100644 --- a/src/main/java/com/youlai/boot/app/service/impl/AppUserServiceImpl.java +++ b/src/main/java/com/youlai/boot/app/service/impl/AppUserServiceImpl.java @@ -21,7 +21,7 @@ import com.youlai.boot.framework.integration.mail.service.MailService; import com.youlai.boot.framework.integration.sms.enums.SmsTypeEnum; import com.youlai.boot.framework.integration.sms.service.SmsService; import com.youlai.boot.framework.security.model.RoleDataScope; -import com.youlai.boot.framework.security.model.UserAuthInfo; +import com.youlai.boot.framework.security.model.SecurityUser; import com.youlai.boot.framework.security.token.TokenManager; import com.youlai.boot.framework.security.util.SecurityUtils; import com.youlai.boot.system.converter.UserConverter; @@ -208,11 +208,11 @@ public class AppUserServiceImpl extends ServiceImpl impl * 根据用户名获取认证凭证信息 * * @param username 用户名 - * @return 用户认证凭证信息 {@link UserAuthInfo} + * @return 用户认证凭证信息 {@link SecurityUser} */ @Override - public UserAuthInfo getAuthInfoByUsername(String username) { - UserAuthInfo userAuthInfo = this.baseMapper.getAuthInfoByUsername(username); + public SecurityUser getAuthInfoByUsername(String username) { + SecurityUser userAuthInfo = this.baseMapper.getAuthInfoByUsername(username); if (userAuthInfo != null) { Set roles = userAuthInfo.getRoles(); // 获取数据权限列表(用于并集策略) @@ -229,11 +229,11 @@ public class AppUserServiceImpl extends ServiceImpl impl * @return 用户认证信息 */ @Override - public UserAuthInfo getAuthInfoByMobile(String mobile) { + public SecurityUser getAuthInfoByMobile(String mobile) { if (StrUtil.isBlank(mobile)) { return null; } - UserAuthInfo userAuthInfo = this.baseMapper.getAuthInfoByMobile(mobile); + SecurityUser userAuthInfo = this.baseMapper.getAuthInfoByMobile(mobile); if (userAuthInfo != null) { Set roles = userAuthInfo.getRoles(); // 获取数据权限列表(用于并集策略) diff --git a/src/main/java/com/youlai/boot/app/service/impl/LocalAppFileService.java b/src/main/java/com/youlai/boot/app/service/impl/LocalAppFileService.java index 9d51f739..b9064922 100644 --- a/src/main/java/com/youlai/boot/app/service/impl/LocalAppFileService.java +++ b/src/main/java/com/youlai/boot/app/service/impl/LocalAppFileService.java @@ -34,7 +34,7 @@ import java.time.LocalDateTime; @RequiredArgsConstructor public class LocalAppFileService implements AppFileService { - @Value("${oss.local.storage-path}") + @Value("${file-storage.local.path}") private String storagePath; /** diff --git a/src/main/java/com/youlai/boot/auth/controller/AuthController.java b/src/main/java/com/youlai/boot/auth/controller/AuthController.java index f3884f0e..64977a50 100644 --- a/src/main/java/com/youlai/boot/auth/controller/AuthController.java +++ b/src/main/java/com/youlai/boot/auth/controller/AuthController.java @@ -5,8 +5,8 @@ import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; import com.youlai.boot.common.result.Result; import com.youlai.boot.auth.service.AuthService; -import com.youlai.boot.common.annotation.Log; -import com.youlai.boot.common.annotation.RateLimit; +import com.youlai.boot.framework.annotation.Log; +import com.youlai.boot.framework.annotation.RateLimit; import com.youlai.boot.framework.captcha.model.CaptchaInfo; import com.youlai.boot.framework.security.model.AuthenticationToken; import io.swagger.v3.oas.annotations.Operation; diff --git a/src/main/java/com/youlai/boot/auth/controller/WxMaAuthController.java b/src/main/java/com/youlai/boot/auth/controller/WxMaAuthController.java index 760295fe..44e5ab9b 100644 --- a/src/main/java/com/youlai/boot/auth/controller/WxMaAuthController.java +++ b/src/main/java/com/youlai/boot/auth/controller/WxMaAuthController.java @@ -4,8 +4,8 @@ import com.youlai.boot.auth.model.form.WxMaBindMobileForm; import com.youlai.boot.auth.model.form.WxMaPhoneLoginForm; import com.youlai.boot.auth.model.vo.WxMaLoginVO; import com.youlai.boot.auth.service.WxMaAuthService; -import com.youlai.boot.common.annotation.Log; -import com.youlai.boot.common.annotation.RateLimit; +import com.youlai.boot.framework.annotation.Log; +import com.youlai.boot.framework.annotation.RateLimit; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; import com.youlai.boot.common.result.Result; diff --git a/src/main/java/com/youlai/boot/auth/qrcode/controller/QrCodeLoginController.java b/src/main/java/com/youlai/boot/auth/qrcode/controller/QrCodeLoginController.java new file mode 100644 index 00000000..22765993 --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/qrcode/controller/QrCodeLoginController.java @@ -0,0 +1,89 @@ +package com.youlai.boot.auth.qrcode.controller; + +import cn.hutool.core.util.StrUtil; +import com.youlai.boot.auth.qrcode.model.form.QrCodeTicketForm; +import com.youlai.boot.auth.qrcode.model.vo.QrCodeGenerateVO; +import com.youlai.boot.auth.qrcode.model.vo.QrCodeStatusVO; +import com.youlai.boot.auth.qrcode.service.QrCodeLoginService; +import com.youlai.boot.framework.annotation.RateLimit; +import com.youlai.boot.common.result.Result; +import com.youlai.boot.framework.security.model.AuthenticationToken; +import com.youlai.boot.framework.security.util.SecurityUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 扫码登录接口。 + *

+ * generate/status/login 不需要登录态(PC 端未登录),由 Security 配置放行; + * scan/confirm/cancel 需要 APP 端登录态,当前用户 ID 从 Security 上下文获取。 + */ +@Tag(name = "02.扫码登录") +@RestController +@RequestMapping("/api/v1/auth/qr-code") +@RequiredArgsConstructor +public class QrCodeLoginController { + + private final QrCodeLoginService qrCodeLoginService; + + @Operation(summary = "生成扫码登录票据") + @PostMapping("/generate") + @RateLimit(limit = 30, window = 60) + public Result generate(HttpServletRequest request) { + return Result.success(qrCodeLoginService.generate(getClientIp(request))); + } + + @Operation(summary = "查询扫码状态") + @GetMapping("/status") + @RateLimit(limit = 60, window = 60) + public Result status(@RequestParam String ticket) { + return Result.success(qrCodeLoginService.status(ticket)); + } + + @Operation(summary = "APP 标记已扫码") + @PostMapping("/scan") + public Result scan(@RequestBody @Valid QrCodeTicketForm form) { + return Result.success(qrCodeLoginService.scan(form.getTicket(), SecurityUtils.getUserId())); + } + + @Operation(summary = "APP 确认登录") + @PostMapping("/confirm") + public Result confirm(@RequestBody @Valid QrCodeTicketForm form) { + return Result.success(qrCodeLoginService.confirm(form.getTicket(), SecurityUtils.getUserId())); + } + + @Operation(summary = "APP 取消登录") + @PostMapping("/cancel") + public Result cancel(@RequestBody @Valid QrCodeTicketForm form) { + return Result.success(qrCodeLoginService.cancel(form.getTicket(), SecurityUtils.getUserId())); + } + + @Operation(summary = "PC 端换取会话令牌") + @PostMapping("/login") + public Result login(@RequestBody @Valid QrCodeTicketForm form) { + return Result.success(qrCodeLoginService.login(form.getTicket())); + } + + /** 从请求头或连接信息中提取客户端 IP,兼容反向代理 */ + private String getClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (StrUtil.isNotBlank(ip) && !"unknown".equalsIgnoreCase(ip)) { + int comma = ip.indexOf(','); + return comma > 0 ? ip.substring(0, comma).trim() : ip.trim(); + } + ip = request.getHeader("X-Real-IP"); + if (StrUtil.isNotBlank(ip) && !"unknown".equalsIgnoreCase(ip)) { + return ip.trim(); + } + return request.getRemoteAddr(); + } +} diff --git a/src/main/java/com/youlai/boot/auth/qrcode/model/QrCodeLoginContext.java b/src/main/java/com/youlai/boot/auth/qrcode/model/QrCodeLoginContext.java new file mode 100644 index 00000000..724c4f2e --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/qrcode/model/QrCodeLoginContext.java @@ -0,0 +1,39 @@ +package com.youlai.boot.auth.qrcode.model; + +import lombok.Data; + +/** + * 扫码登录票据上下文,序列化为 JSON 存入 Redis。 + *

+ * 字段说明见 docs/youlai-boot/scan-code-login.md 的 Redis 存储设计。 + */ +@Data +public class QrCodeLoginContext { + + /** 票据,UUID 无连字符 */ + private String ticket; + + /** 状态枚举名,取 {@link QrCodeLoginStatusEnum#name()} */ + private String status; + + /** 扫码用户 ID,scan 时写入 */ + private Long userId; + + /** 用户原始昵称(未脱敏),status 接口返回时脱敏 */ + private String nickname; + + /** 用户头像 URL */ + private String avatar; + + /** 创建时间戳(毫秒) */ + private Long createdAt; + + /** 扫码时间戳(毫秒) */ + private Long scannedAt; + + /** 确认时间戳(毫秒) */ + private Long confirmedAt; + + /** generate 时的 PC 端 IP,用于审计 */ + private String clientIp; +} diff --git a/src/main/java/com/youlai/boot/auth/qrcode/model/QrCodeLoginStatusEnum.java b/src/main/java/com/youlai/boot/auth/qrcode/model/QrCodeLoginStatusEnum.java new file mode 100644 index 00000000..1f993c6e --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/qrcode/model/QrCodeLoginStatusEnum.java @@ -0,0 +1,21 @@ +package com.youlai.boot.auth.qrcode.model; + +/** + * 扫码登录票据状态。 + *

+ * WAITING 票据已创建,等待 APP 扫码 + * SCANNED APP 已扫码,等待用户在手机上确认 + * CONFIRMED 用户已在 APP 上确认登录 + * LOGGED_IN PC 已用票据换取会话令牌,票据作废,不可再用 + * CANCELED 用户在 APP 上取消登录 + * EXPIRED 票据超时,由 Redis TTL 自动清理,内存中通常不会出现该值 + */ +public enum QrCodeLoginStatusEnum { + + WAITING, + SCANNED, + CONFIRMED, + LOGGED_IN, + CANCELED, + EXPIRED +} diff --git a/src/main/java/com/youlai/boot/auth/qrcode/model/form/QrCodeTicketForm.java b/src/main/java/com/youlai/boot/auth/qrcode/model/form/QrCodeTicketForm.java new file mode 100644 index 00000000..51d151b7 --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/qrcode/model/form/QrCodeTicketForm.java @@ -0,0 +1,14 @@ +package com.youlai.boot.auth.qrcode.model.form; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 扫码登录票据表单,用于 scan/confirm/cancel/login 接口。 + */ +@Data +public class QrCodeTicketForm { + + @NotBlank(message = "票据不能为空") + private String ticket; +} diff --git a/src/main/java/com/youlai/boot/auth/qrcode/model/vo/QrCodeGenerateVO.java b/src/main/java/com/youlai/boot/auth/qrcode/model/vo/QrCodeGenerateVO.java new file mode 100644 index 00000000..117ef015 --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/qrcode/model/vo/QrCodeGenerateVO.java @@ -0,0 +1,20 @@ +package com.youlai.boot.auth.qrcode.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +/** + * generate 接口响应。 + */ +@Data +@Builder +@Schema(description = "扫码票据生成结果") +public class QrCodeGenerateVO { + + @Schema(description = "票据") + private String ticket; + + @Schema(description = "有效期(秒)") + private Integer expireSeconds; +} diff --git a/src/main/java/com/youlai/boot/auth/qrcode/model/vo/QrCodeStatusVO.java b/src/main/java/com/youlai/boot/auth/qrcode/model/vo/QrCodeStatusVO.java new file mode 100644 index 00000000..9b3d649e --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/qrcode/model/vo/QrCodeStatusVO.java @@ -0,0 +1,29 @@ +package com.youlai.boot.auth.qrcode.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +/** + * status/scan/confirm/cancel 接口响应。 + */ +@Data +@Builder +@Schema(description = "扫码登录状态") +public class QrCodeStatusVO { + + @Schema(description = "票据") + private String ticket; + + @Schema(description = "状态:WAITING/SCANNED/CONFIRMED/LOGGED_IN/CANCELED/EXPIRED") + private String status; + + @Schema(description = "脱敏昵称,SCANNED 之后返回") + private String nickname; + + @Schema(description = "头像 URL,SCANNED 之后返回") + private String avatar; + + @Schema(description = "剩余有效期(秒)") + private Integer expireSeconds; +} diff --git a/src/main/java/com/youlai/boot/auth/qrcode/service/QrCodeLoginService.java b/src/main/java/com/youlai/boot/auth/qrcode/service/QrCodeLoginService.java new file mode 100644 index 00000000..9c8a2012 --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/qrcode/service/QrCodeLoginService.java @@ -0,0 +1,29 @@ +package com.youlai.boot.auth.qrcode.service; + +import com.youlai.boot.auth.qrcode.model.vo.QrCodeGenerateVO; +import com.youlai.boot.auth.qrcode.model.vo.QrCodeStatusVO; +import com.youlai.boot.framework.security.model.AuthenticationToken; + +/** + * 扫码登录服务。 + */ +public interface QrCodeLoginService { + + /** 生成票据,未登录调用 */ + QrCodeGenerateVO generate(String clientIp); + + /** 查询状态,未登录调用 */ + QrCodeStatusVO status(String ticket); + + /** APP 标记已扫码,需 APP 端已登录 */ + QrCodeStatusVO scan(String ticket, Long userId); + + /** APP 确认登录,需 APP 端已登录 */ + QrCodeStatusVO confirm(String ticket, Long userId); + + /** APP 取消登录,需 APP 端已登录 */ + QrCodeStatusVO cancel(String ticket, Long userId); + + /** PC 端用票据换取会话令牌,未登录调用 */ + AuthenticationToken login(String ticket); +} diff --git a/src/main/java/com/youlai/boot/auth/qrcode/service/impl/QrCodeLoginServiceImpl.java b/src/main/java/com/youlai/boot/auth/qrcode/service/impl/QrCodeLoginServiceImpl.java new file mode 100644 index 00000000..9a488c60 --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/qrcode/service/impl/QrCodeLoginServiceImpl.java @@ -0,0 +1,228 @@ +package com.youlai.boot.auth.qrcode.service.impl; + +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import com.youlai.boot.auth.qrcode.model.QrCodeLoginContext; +import com.youlai.boot.auth.qrcode.model.QrCodeLoginStatusEnum; +import com.youlai.boot.auth.qrcode.model.vo.QrCodeGenerateVO; +import com.youlai.boot.auth.qrcode.model.vo.QrCodeStatusVO; +import com.youlai.boot.auth.qrcode.service.QrCodeLoginService; +import com.youlai.boot.auth.qrcode.util.QrCodeNicknameMasker; +import com.youlai.boot.common.constant.RedisConstants; +import com.youlai.boot.common.result.ResultCode; +import com.youlai.boot.framework.security.exception.TokenInvalidException; +import com.youlai.boot.framework.security.model.AuthenticationToken; +import com.youlai.boot.framework.security.model.SecurityUser; +import com.youlai.boot.framework.security.model.SecurityUserDetails; +import com.youlai.boot.framework.security.token.TokenManager; +import com.youlai.boot.system.service.UserSocialService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import tools.jackson.databind.json.JsonMapper; + +import java.util.concurrent.TimeUnit; + +/** + * 扫码登录服务实现。 + *

+ * 票据上下文以 JSON 形式存入 Redis,TTL 与有效期一致;状态流转的合法性全部由 + * {@link #requireStatus} / {@link #requireSameUser} 在写入前拦截,非法迁移直接抛业务异常, + * 因此同一 ticket 被多端并发操作时不需要分布式锁——最坏情况是后到的操作在状态校验处失败。 + * PC 端换取会话时直接复用 {@link TokenManager#generateToken(Authentication)}, + * 生成的令牌与账号密码登录走的是同一套会话治理(单/多设备、登出、刷新),前端无差别处理。 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class QrCodeLoginServiceImpl implements QrCodeLoginService { + + /** 票据默认有效期(秒),二维码在此时间内未走完流程即由 Redis TTL 自动清理 */ + private static final int DEFAULT_EXPIRE_SECONDS = 300; + + /** 状态流转时若 Redis 剩余 TTL 小于该值,补足到此值,避免临界点票据在下一步操作前被清掉 */ + private static final int MIN_REMAIN_SECONDS = 30; + + /** + * 从 Redis 读回的值可能是不带类型信息的 Map(序列化器以 Object.class 存储), + * 用它把 Map 转回强类型上下文对象。JsonMapper 线程安全,复用单例。 + */ + private static final JsonMapper JSON_MAPPER = JsonMapper.builder().build(); + + private final RedisTemplate redisTemplate; + private final TokenManager tokenManager; + private final UserSocialService userSocialService; + + @Override + public QrCodeGenerateVO generate(String clientIp) { + String ticket = IdUtil.fastSimpleUUID(); + QrCodeLoginContext ctx = new QrCodeLoginContext(); + ctx.setTicket(ticket); + ctx.setStatus(QrCodeLoginStatusEnum.WAITING.name()); + ctx.setCreatedAt(System.currentTimeMillis()); + ctx.setClientIp(clientIp); + save(ctx, DEFAULT_EXPIRE_SECONDS); + return QrCodeGenerateVO.builder() + .ticket(ticket) + .expireSeconds(DEFAULT_EXPIRE_SECONDS) + .build(); + } + + @Override + public QrCodeStatusVO status(String ticket) { + QrCodeLoginContext ctx = loadOrThrow(ticket); + return toVO(ctx, remainingSeconds(ticket)); + } + + @Override + public QrCodeStatusVO scan(String ticket, Long userId) { + QrCodeLoginContext ctx = loadOrThrow(ticket); + requireStatus(ctx, QrCodeLoginStatusEnum.WAITING); + fillUserInfo(ctx, userId); + ctx.setStatus(QrCodeLoginStatusEnum.SCANNED.name()); + ctx.setScannedAt(System.currentTimeMillis()); + save(ctx, refreshTtl(ticket)); + return toVO(ctx, remainingSeconds(ticket)); + } + + @Override + public QrCodeStatusVO confirm(String ticket, Long userId) { + QrCodeLoginContext ctx = loadOrThrow(ticket); + requireStatus(ctx, QrCodeLoginStatusEnum.SCANNED); + requireSameUser(ctx, userId); + ctx.setStatus(QrCodeLoginStatusEnum.CONFIRMED.name()); + ctx.setConfirmedAt(System.currentTimeMillis()); + save(ctx, refreshTtl(ticket)); + return toVO(ctx, remainingSeconds(ticket)); + } + + @Override + public QrCodeStatusVO cancel(String ticket, Long userId) { + QrCodeLoginContext ctx = loadOrThrow(ticket); + QrCodeLoginStatusEnum current = QrCodeLoginStatusEnum.valueOf(ctx.getStatus()); + // 只有还在流程中的票据(等待扫码 / 已扫码 / 已确认)允许取消,已登录或已取消的重复操作直接拒绝 + if (current != QrCodeLoginStatusEnum.WAITING + && current != QrCodeLoginStatusEnum.SCANNED + && current != QrCodeLoginStatusEnum.CONFIRMED) { + throw new TokenInvalidException(ResultCode.QR_CODE_STATUS_ILLEGAL); + } + // 一旦有人扫过码,取消权就归扫码本人,防止他人替扫码用户取消 + if (current != QrCodeLoginStatusEnum.WAITING && ctx.getUserId() != null) { + requireSameUser(ctx, userId); + } + ctx.setStatus(QrCodeLoginStatusEnum.CANCELED.name()); + save(ctx, refreshTtl(ticket)); + return toVO(ctx, remainingSeconds(ticket)); + } + + @Override + public AuthenticationToken login(String ticket) { + QrCodeLoginContext ctx = loadOrThrow(ticket); + requireStatus(ctx, QrCodeLoginStatusEnum.CONFIRMED); + SecurityUser securityUser = userSocialService.getAuthInfoByUserId(ctx.getUserId()); + if (securityUser == null) { + throw new TokenInvalidException(ResultCode.ACCOUNT_NOT_FOUND); + } + SecurityUserDetails details = new SecurityUserDetails(securityUser); + Authentication authentication = new UsernamePasswordAuthenticationToken( + details, null, details.getAuthorities()); + AuthenticationToken token = tokenManager.generateToken(authentication); + // 换取令牌成功后立即把票据置为已使用(一次性),再次 login 会在 requireStatus(CONFIRMED) 处被拒,杜绝重放 + ctx.setStatus(QrCodeLoginStatusEnum.LOGGED_IN.name()); + save(ctx, Math.max(remainingSeconds(ticket), MIN_REMAIN_SECONDS)); + return token; + } + + // ======================== private ======================== + + /** 读取票据上下文,票据为空、不存在或已过期都视为 QR_CODE_NOT_FOUND */ + private QrCodeLoginContext loadOrThrow(String ticket) { + if (StrUtil.isBlank(ticket)) { + throw new TokenInvalidException(ResultCode.QR_CODE_NOT_FOUND); + } + Object raw = redisTemplate.opsForValue().get(key(ticket)); + if (raw == null) { + throw new TokenInvalidException(ResultCode.QR_CODE_NOT_FOUND); + } + if (raw instanceof QrCodeLoginContext ctx) { + return ctx; + } + // 序列化器以 Object.class 存储、未带类型信息,读回来是 Map,这里再转回强类型 + try { + return JSON_MAPPER.convertValue(raw, QrCodeLoginContext.class); + } catch (Exception e) { + log.error("扫码登录上下文反序列化失败, ticket={}", ticket, e); + throw new TokenInvalidException(ResultCode.QR_CODE_NOT_FOUND); + } + } + + /** 当前状态必须等于期望状态,否则说明流程被跳步或重复操作 */ + private void requireStatus(QrCodeLoginContext ctx, QrCodeLoginStatusEnum expected) { + if (!expected.name().equals(ctx.getStatus())) { + throw new TokenInvalidException(ResultCode.QR_CODE_STATUS_ILLEGAL); + } + } + + /** 操作者必须是当初扫码的那个用户,防止 A 扫码 B 确认 */ + private void requireSameUser(QrCodeLoginContext ctx, Long userId) { + if (ctx.getUserId() == null || !ctx.getUserId().equals(userId)) { + throw new TokenInvalidException(ResultCode.QR_CODE_USER_MISMATCH); + } + } + + /** 扫码时把当前 APP 用户的昵称、头像写进上下文,供 PC 端 status 展示 */ + private void fillUserInfo(QrCodeLoginContext ctx, Long userId) { + SecurityUser info = userSocialService.getAuthInfoByUserId(userId); + if (info == null) { + throw new TokenInvalidException(ResultCode.ACCOUNT_NOT_FOUND); + } + ctx.setUserId(userId); + ctx.setNickname(info.getNickname()); + ctx.setAvatar(info.getAvatar()); + } + + /** 重写整个上下文并刷新 TTL */ + private void save(QrCodeLoginContext ctx, int ttl) { + redisTemplate.opsForValue().set(key(ctx.getTicket()), ctx, ttl, TimeUnit.SECONDS); + } + + /** 票据在 Redis 中的剩余秒数,取不到或已过期返回 0 */ + private int remainingSeconds(String ticket) { + Long ttl = redisTemplate.getExpire(key(ticket), TimeUnit.SECONDS); + return ttl == null ? 0 : Math.max(ttl.intValue(), 0); + } + + /** 状态流转时写回的 TTL:维持剩余时间,不足 MIN_REMAIN_SECONDS 则补足 */ + private int refreshTtl(String ticket) { + int remain = remainingSeconds(ticket); + return remain < MIN_REMAIN_SECONDS ? MIN_REMAIN_SECONDS : remain; + } + + /** 上下文转前端 VO,昵称脱敏、用户信息仅在扫码后暴露 */ + private QrCodeStatusVO toVO(QrCodeLoginContext ctx, int expireSeconds) { + QrCodeLoginStatusEnum status = QrCodeLoginStatusEnum.valueOf(ctx.getStatus()); + String nickname = null; + String avatar = null; + // WAITING 阶段谁都能查状态,此时不能泄露用户信息;扫码/确认后才回传脱敏昵称与头像 + if (status == QrCodeLoginStatusEnum.SCANNED + || status == QrCodeLoginStatusEnum.CONFIRMED) { + nickname = QrCodeNicknameMasker.mask(ctx.getNickname()); + avatar = ctx.getAvatar(); + } + return QrCodeStatusVO.builder() + .ticket(ctx.getTicket()) + .status(status.name()) + .nickname(nickname) + .avatar(avatar) + .expireSeconds(expireSeconds) + .build(); + } + + /** 拼接票据在 Redis 中的完整 Key */ + private String key(String ticket) { + return StrUtil.format(RedisConstants.Auth.QR_CODE_LOGIN, ticket); + } +} diff --git a/src/main/java/com/youlai/boot/auth/qrcode/util/QrCodeNicknameMasker.java b/src/main/java/com/youlai/boot/auth/qrcode/util/QrCodeNicknameMasker.java new file mode 100644 index 00000000..01fcc760 --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/qrcode/util/QrCodeNicknameMasker.java @@ -0,0 +1,34 @@ +package com.youlai.boot.auth.qrcode.util; + +/** + * 昵称脱敏:保留首尾各一个字符,中间用 * 替换。 + *

+ * 长度 1:原样返回 + * 长度 2:首字 + * + * 长度 ≥3:首字 + (n-2) 个 * + 末字 + */ +public final class QrCodeNicknameMasker { + + private QrCodeNicknameMasker() { + } + + public static String mask(String nickname) { + if (nickname == null || nickname.isEmpty()) { + return ""; + } + int len = nickname.length(); + if (len == 1) { + return nickname; + } + if (len == 2) { + return nickname.charAt(0) + "*"; + } + StringBuilder sb = new StringBuilder(); + sb.append(nickname.charAt(0)); + for (int i = 0; i < len - 2; i++) { + sb.append('*'); + } + sb.append(nickname.charAt(len - 1)); + return sb.toString(); + } +} diff --git a/src/main/java/com/youlai/boot/framework/security/config/SecurityConfig.java b/src/main/java/com/youlai/boot/auth/security/config/SecurityConfig.java similarity index 63% rename from src/main/java/com/youlai/boot/framework/security/config/SecurityConfig.java rename to src/main/java/com/youlai/boot/auth/security/config/SecurityConfig.java index da39053e..2c8412d6 100644 --- a/src/main/java/com/youlai/boot/framework/security/config/SecurityConfig.java +++ b/src/main/java/com/youlai/boot/auth/security/config/SecurityConfig.java @@ -1,18 +1,20 @@ -package com.youlai.boot.framework.security.config; +package com.youlai.boot.auth.security.config; import cn.binarywang.wx.miniapp.api.WxMaService; import cn.hutool.core.util.ArrayUtil; import com.youlai.boot.framework.security.filter.MobileApiSignatureFilter; import com.youlai.boot.framework.captcha.service.CaptchaService; -import com.youlai.boot.framework.security.filter.CaptchaValidationFilter; +import com.youlai.boot.framework.security.config.SecurityProperties; import com.youlai.boot.framework.security.filter.TokenAuthenticationFilter; -import com.youlai.boot.framework.security.handler.MyAccessDeniedHandler; -import com.youlai.boot.framework.security.handler.MyAuthenticationEntryPoint; -import com.youlai.boot.framework.security.provider.SmsAuthenticationProvider; -import com.youlai.boot.framework.security.provider.WxMaAuthenticationProvider; +import com.youlai.boot.framework.security.port.UserAuthenticationPort; +import com.youlai.boot.framework.security.service.SecurityUserDetailsService; import com.youlai.boot.framework.security.token.TokenManager; -import com.youlai.boot.framework.security.service.SysUserDetailsService; -import com.youlai.boot.system.service.UserService; +import com.youlai.boot.auth.security.filter.CaptchaValidationFilter; +import com.youlai.boot.auth.security.handler.JsonAccessDeniedHandler; +import com.youlai.boot.auth.security.handler.JsonAuthenticationEntryPoint; +import com.youlai.boot.auth.security.provider.SmsAuthenticationProvider; +import com.youlai.boot.auth.security.provider.WxMaAuthenticationProvider; +import com.youlai.boot.system.service.UserSocialService; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -30,9 +32,13 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.access.intercept.AuthorizationFilter; /** - * Spring Security 配置类 + * Spring Security 配置类。 + *

+ * 归使用方(auth 模块),安全规则(放行路径、CORS、Provider 装配、响应格式) + * 因项目而异,不应由框架层强制装配。 * * @author Ray.Hao * @since 4.3.1 @@ -45,22 +51,15 @@ public class SecurityConfig { private final RedisTemplate redisTemplate; private final PasswordEncoder passwordEncoder; - private final TokenManager tokenManager; - private final UserService userService; - private final SysUserDetailsService userDetailsService; - + private final SecurityUserDetailsService userDetailsService; private final CaptchaService captchaService; private final SecurityProperties securityProperties; - /** - * 配置安全过滤链 SecurityFilterChain - */ @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http .authorizeHttpRequests(requestMatcherRegistry -> { - // 配置无需登录即可访问的公开接口(配置文件方式) String[] ignoreUrls = securityProperties.getIgnoreUrls(); if (ArrayUtil.isNotEmpty(ignoreUrls)) { requestMatcherRegistry.requestMatchers(ignoreUrls).permitAll(); @@ -75,33 +74,25 @@ public class SecurityConfig { ) .exceptionHandling(configurer -> configurer - .authenticationEntryPoint(new MyAuthenticationEntryPoint()) // 未认证异常处理器 - .accessDeniedHandler(new MyAccessDeniedHandler()) // 无权限访问异常处理器 + .authenticationEntryPoint(new JsonAuthenticationEntryPoint()) + .accessDeniedHandler(new JsonAccessDeniedHandler()) ) - - // 禁用默认的 Spring Security 特性,适用于前后端分离架构 .sessionManagement(configurer -> - configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 无状态认证,不使用 Session + configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) - .csrf(AbstractHttpConfigurer::disable) // 禁用 CSRF 防护,前后端分离无需此防护机制 - .formLogin(AbstractHttpConfigurer::disable) // 禁用默认的表单登录功能,前后端分离采用 Token 认证方式 - .httpBasic(AbstractHttpConfigurer::disable) // 禁用 HTTP Basic 认证,避免弹窗式登录 - // 禁用 X-Frame-Options 响应头,允许页面被嵌套到 iframe 中 + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) - // 验证码校验过滤器 + // 验证码校验(使用方过滤器,直接写 JSON 响应) .addFilterBefore(new CaptchaValidationFilter(captchaService), UsernamePasswordAuthenticationFilter.class) + // Token 认证(Starter 过滤器,抛 AuthenticationException 交给 ExceptionTranslationFilter 处理) + .addFilterBefore(new TokenAuthenticationFilter(tokenManager), AuthorizationFilter.class) // 移动设备API签名验证过滤器(仅对 /api/v1/sn/** 路径生效) .addFilterBefore(new MobileApiSignatureFilter(redisTemplate), UsernamePasswordAuthenticationFilter.class) - // 验证和解析过滤器 - .addFilterBefore(new TokenAuthenticationFilter(tokenManager), UsernamePasswordAuthenticationFilter.class) .build(); } - /** - * 配置Web安全自定义器,以忽略特定请求路径的安全性检查。 - *

- * 该配置用于指定哪些请求路径不经过Spring Security过滤器链。通常用于静态资源文件。 - */ @Bean public WebSecurityCustomizer webSecurityCustomizer() { return (web) -> { @@ -112,38 +103,27 @@ public class SecurityConfig { }; } - /** - * 默认密码认证的 Provider - */ @Bean public DaoAuthenticationProvider daoAuthenticationProvider() { - DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider(userDetailsService); - daoAuthenticationProvider.setPasswordEncoder(passwordEncoder); - return daoAuthenticationProvider; + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService); + provider.setPasswordEncoder(passwordEncoder); + return provider; } - /** - * 短信验证码认证 Provider - */ @Bean - public SmsAuthenticationProvider smsAuthenticationProvider() { - return new SmsAuthenticationProvider(userService, redisTemplate); + public SmsAuthenticationProvider smsAuthenticationProvider(UserAuthenticationPort userAuthPort) { + return new SmsAuthenticationProvider(userAuthPort, redisTemplate); } - /** - * 微信小程序认证 Provider - */ @Bean public WxMaAuthenticationProvider wechatMiniAuthenticationProvider( WxMaService wxMaService, - SysUserDetailsService sysUserDetailsService + UserAuthenticationPort userAuthenticationPort, + UserSocialService userSocialService ) { - return new WxMaAuthenticationProvider(wxMaService, sysUserDetailsService); + return new WxMaAuthenticationProvider(wxMaService, userAuthenticationPort, userSocialService); } - /** - * 认证管理器 - */ @Bean public AuthenticationManager authenticationManager( DaoAuthenticationProvider daoAuthenticationProvider, @@ -156,5 +136,4 @@ public class SecurityConfig { wxMaAuthenticationProvider ); } - } diff --git a/src/main/java/com/youlai/boot/framework/security/exception/NeedBindMobileException.java b/src/main/java/com/youlai/boot/auth/security/exception/MobileNotBoundException.java similarity index 60% rename from src/main/java/com/youlai/boot/framework/security/exception/NeedBindMobileException.java rename to src/main/java/com/youlai/boot/auth/security/exception/MobileNotBoundException.java index 706b6179..099b9ee3 100644 --- a/src/main/java/com/youlai/boot/framework/security/exception/NeedBindMobileException.java +++ b/src/main/java/com/youlai/boot/auth/security/exception/MobileNotBoundException.java @@ -1,17 +1,16 @@ -package com.youlai.boot.framework.security.exception; +package com.youlai.boot.auth.security.exception; import org.springframework.security.core.AuthenticationException; /** - * 需要绑定手机号异常 + * 需要绑定手机号异常(微信小程序登录未绑定手机号时抛出)。 */ -public class NeedBindMobileException extends AuthenticationException { +public class MobileNotBoundException extends AuthenticationException { private final String openid; - private final String sessionKey; - public NeedBindMobileException(String openid, String sessionKey) { + public MobileNotBoundException(String openid, String sessionKey) { super("需要绑定手机号"); this.openid = openid; this.sessionKey = sessionKey; @@ -24,5 +23,4 @@ public class NeedBindMobileException extends AuthenticationException { public String getSessionKey() { return sessionKey; } - } diff --git a/src/main/java/com/youlai/boot/framework/security/exception/SmsCaptchaException.java b/src/main/java/com/youlai/boot/auth/security/exception/SmsCaptchaException.java similarity index 83% rename from src/main/java/com/youlai/boot/framework/security/exception/SmsCaptchaException.java rename to src/main/java/com/youlai/boot/auth/security/exception/SmsCaptchaException.java index 8fca2cd9..d5bfd0ea 100644 --- a/src/main/java/com/youlai/boot/framework/security/exception/SmsCaptchaException.java +++ b/src/main/java/com/youlai/boot/auth/security/exception/SmsCaptchaException.java @@ -1,4 +1,4 @@ -package com.youlai.boot.framework.security.exception; +package com.youlai.boot.auth.security.exception; import org.springframework.security.core.AuthenticationException; @@ -9,6 +9,7 @@ import org.springframework.security.core.AuthenticationException; * @since 2025/3/1 */ public class SmsCaptchaException extends AuthenticationException { + public SmsCaptchaException(String msg) { super(msg); } diff --git a/src/main/java/com/youlai/boot/framework/security/filter/CaptchaValidationFilter.java b/src/main/java/com/youlai/boot/auth/security/filter/CaptchaValidationFilter.java similarity index 93% rename from src/main/java/com/youlai/boot/framework/security/filter/CaptchaValidationFilter.java rename to src/main/java/com/youlai/boot/auth/security/filter/CaptchaValidationFilter.java index 30504bc2..db2b4973 100644 --- a/src/main/java/com/youlai/boot/framework/security/filter/CaptchaValidationFilter.java +++ b/src/main/java/com/youlai/boot/auth/security/filter/CaptchaValidationFilter.java @@ -1,4 +1,4 @@ -package com.youlai.boot.framework.security.filter; +package com.youlai.boot.auth.security.filter; import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONObject; @@ -29,7 +29,9 @@ import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; /** - * 图形验证码校验过滤器 + * 图形验证码校验过滤器。 + *

+ * 归使用方,因为验证码规则(哪些接口需要验证码、验证码类型)因项目而异。 */ public class CaptchaValidationFilter extends OncePerRequestFilter { @@ -49,20 +51,17 @@ public class CaptchaValidationFilter extends OncePerRequestFilter { public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { - // 非登录接口直接放行 if (!LOGIN_PATH_REQUEST_MATCHER.matches(request)) { chain.doFilter(request, response); return; } - // 仅支持 JSON 登录 String contentType = request.getContentType(); if (contentType == null || !contentType.contains(MediaType.APPLICATION_JSON_VALUE)) { ResponseWriter.writeError(response, ResultCode.USER_VERIFICATION_CODE_ERROR); return; } - // 包装请求,确保下游还能读取 body ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request, -1); byte[] bodyBytes = StreamUtils.copyToByteArray(requestWrapper.getInputStream()); @@ -85,9 +84,6 @@ public class CaptchaValidationFilter extends OncePerRequestFilter { } } - /** - * Simple wrapper to allow repeated reads of the request body after we've parsed it here. - */ private static class RepeatableReadRequestWrapper extends HttpServletRequestWrapper { private final byte[] cachedBody; @@ -139,5 +135,3 @@ public class CaptchaValidationFilter extends OncePerRequestFilter { } } } - - diff --git a/src/main/java/com/youlai/boot/framework/security/handler/MyAccessDeniedHandler.java b/src/main/java/com/youlai/boot/auth/security/handler/JsonAccessDeniedHandler.java similarity index 64% rename from src/main/java/com/youlai/boot/framework/security/handler/MyAccessDeniedHandler.java rename to src/main/java/com/youlai/boot/auth/security/handler/JsonAccessDeniedHandler.java index b4b28ab4..3124829f 100644 --- a/src/main/java/com/youlai/boot/framework/security/handler/MyAccessDeniedHandler.java +++ b/src/main/java/com/youlai/boot/auth/security/handler/JsonAccessDeniedHandler.java @@ -1,4 +1,4 @@ -package com.youlai.boot.framework.security.handler; +package com.youlai.boot.auth.security.handler; import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.framework.web.util.ResponseWriter; @@ -9,17 +9,18 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; /** - * 无权限访问处理器 + * 无权限访问处理器。 + *

+ * 归使用方,因为 JSON 响应格式因项目而异。 * * @author Ray.Hao * @since 2.0.0 */ -public class MyAccessDeniedHandler implements AccessDeniedHandler { +public class JsonAccessDeniedHandler implements AccessDeniedHandler { @Override - public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) { - // 权限不足返回 403 Forbidden + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) { ResponseWriter.writeError(response, ResultCode.ACCESS_PERMISSION_EXCEPTION); } - } diff --git a/src/main/java/com/youlai/boot/framework/security/handler/MyAuthenticationEntryPoint.java b/src/main/java/com/youlai/boot/auth/security/handler/JsonAuthenticationEntryPoint.java similarity index 54% rename from src/main/java/com/youlai/boot/framework/security/handler/MyAuthenticationEntryPoint.java rename to src/main/java/com/youlai/boot/auth/security/handler/JsonAuthenticationEntryPoint.java index 4f4257c1..0a265125 100644 --- a/src/main/java/com/youlai/boot/framework/security/handler/MyAuthenticationEntryPoint.java +++ b/src/main/java/com/youlai/boot/auth/security/handler/JsonAuthenticationEntryPoint.java @@ -1,4 +1,4 @@ -package com.youlai.boot.framework.security.handler; +package com.youlai.boot.auth.security.handler; import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.framework.web.util.ResponseWriter; @@ -14,35 +14,24 @@ import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; /** - * 统一处理 Spring Security 认证失败响应 + * 统一处理 Spring Security 认证失败响应。 + *

+ * 归使用方,因为 JSON 响应格式(Result 结构、错误码)因项目而异。 * * @author Ray.Hao * @since 2.0.0 */ -public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint { +public class JsonAuthenticationEntryPoint implements AuthenticationEntryPoint { - /** - * 认证失败处理入口方法 - * - * @param request 触发异常的请求对象(可用于获取请求头、参数等) - * @param response 响应对象(用于写入错误信息) - * @param authException 认证异常对象(包含具体失败原因) - */ @Override - public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException, ServletException { if (authException instanceof BadCredentialsException) { - // 用户名或密码错误 ResponseWriter.writeError(response, ResultCode.USER_PASSWORD_ERROR); - } else if(authException instanceof InsufficientAuthenticationException){ - // 请求头缺失Authorization、Token格式错误、Token过期、签名验证失败 + } else if (authException instanceof InsufficientAuthenticationException) { ResponseWriter.writeError(response, ResultCode.ACCESS_TOKEN_INVALID); } else { - // 其他未明确处理的认证异常(如账户被锁定、账户禁用等) ResponseWriter.writeError(response, ResultCode.USER_LOGIN_EXCEPTION, authException.getMessage()); } } } - - - - diff --git a/src/main/java/com/youlai/boot/framework/security/model/SmsAuthenticationToken.java b/src/main/java/com/youlai/boot/auth/security/model/SmsAuthenticationToken.java similarity index 54% rename from src/main/java/com/youlai/boot/framework/security/model/SmsAuthenticationToken.java rename to src/main/java/com/youlai/boot/auth/security/model/SmsAuthenticationToken.java index bdcb60fe..064e277b 100644 --- a/src/main/java/com/youlai/boot/framework/security/model/SmsAuthenticationToken.java +++ b/src/main/java/com/youlai/boot/auth/security/model/SmsAuthenticationToken.java @@ -1,4 +1,4 @@ -package com.youlai.boot.framework.security.model; +package com.youlai.boot.auth.security.model; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; @@ -8,13 +8,9 @@ import java.io.Serial; import java.util.Collection; /** - * 短信验证码认证 Token + * 短信验证码认证 Token。 *

- * 用于短信验证码登录场景,遵循 Spring Security 认证模型: - *

    - *
  • 未认证状态:principal 为手机号,credentials 为验证码
  • - *
  • 已认证状态:principal 为用户详情,credentials 为 null
  • - *
+ * 未认证:principal=手机号,credentials=验证码;已认证:principal=SecurityUserDetails,credentials=null。 * * @author Ray.Hao * @since 2.20.0 @@ -24,30 +20,9 @@ public class SmsAuthenticationToken extends AbstractAuthenticationToken { @Serial private static final long serialVersionUID = 621L; - /** - * 认证信息 - *
    - *
  • 未认证时:手机号
  • - *
  • 已认证时:SysUserDetails 用户详情
  • - *
- */ private final Object principal; - - /** - * 凭证信息 - *
    - *
  • 未认证时:短信验证码
  • - *
  • 已认证时:null
  • - *
- */ private final Object credentials; - /** - * 创建未认证的 Token - * - * @param mobile 手机号 - * @param verifyCode 短信验证码 - */ public SmsAuthenticationToken(String mobile, String verifyCode) { super(AuthorityUtils.NO_AUTHORITIES); this.principal = mobile; @@ -55,12 +30,6 @@ public class SmsAuthenticationToken extends AbstractAuthenticationToken { setAuthenticated(false); } - /** - * 创建已认证的 Token - * - * @param principal 用户详情(SysUserDetails) - * @param authorities 授权信息 - */ public SmsAuthenticationToken(Object principal, Collection authorities) { super(authorities); this.principal = principal; @@ -68,13 +37,6 @@ public class SmsAuthenticationToken extends AbstractAuthenticationToken { super.setAuthenticated(true); } - /** - * 创建已认证的 Token(静态工厂方法) - * - * @param principal 用户详情(SysUserDetails) - * @param authorities 授权信息 - * @return 已认证的 SmsAuthenticationToken - */ public static SmsAuthenticationToken authenticated(Object principal, Collection authorities) { return new SmsAuthenticationToken(principal, authorities); } diff --git a/src/main/java/com/youlai/boot/framework/security/model/WxMaAuthenticationToken.java b/src/main/java/com/youlai/boot/auth/security/model/WxMaAuthenticationToken.java similarity index 68% rename from src/main/java/com/youlai/boot/framework/security/model/WxMaAuthenticationToken.java rename to src/main/java/com/youlai/boot/auth/security/model/WxMaAuthenticationToken.java index 584929ef..3f012894 100644 --- a/src/main/java/com/youlai/boot/framework/security/model/WxMaAuthenticationToken.java +++ b/src/main/java/com/youlai/boot/auth/security/model/WxMaAuthenticationToken.java @@ -1,4 +1,4 @@ -package com.youlai.boot.framework.security.model; +package com.youlai.boot.auth.security.model; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; @@ -8,7 +8,9 @@ import java.io.Serial; import java.util.Collection; /** - * 微信小程序认证 Token + * 微信小程序认证 Token。 + *

+ * 未认证:principal=微信code;已认证:principal=SecurityUserDetails。 * * @author Ray.Hao * @since 4.0.0 @@ -18,25 +20,9 @@ public class WxMaAuthenticationToken extends AbstractAuthenticationToken { @Serial private static final long serialVersionUID = 622L; - /** - * 认证信息 - * 未认证时:微信code - * 已认证时:SysUserDetails 用户详情 - */ private final Object principal; - - /** - * 凭证信息 - * 未认证时:null - * 已认证时:null - */ private final Object credentials; - /** - * 创建未认证的 Token - * - * @param code 微信小程序code - */ public WxMaAuthenticationToken(String code) { super(AuthorityUtils.NO_AUTHORITIES); this.principal = code; @@ -44,12 +30,6 @@ public class WxMaAuthenticationToken extends AbstractAuthenticationToken { setAuthenticated(false); } - /** - * 创建已认证的 Token - * - * @param principal 用户详情(SysUserDetails) - * @param authorities 授权信息 - */ public WxMaAuthenticationToken(Object principal, Collection authorities) { super(authorities); this.principal = principal; @@ -57,9 +37,6 @@ public class WxMaAuthenticationToken extends AbstractAuthenticationToken { super.setAuthenticated(true); } - /** - * 创建已认证的 Token(静态工厂方法) - */ public static WxMaAuthenticationToken authenticated(Object principal, Collection authorities) { return new WxMaAuthenticationToken(principal, authorities); } diff --git a/src/main/java/com/youlai/boot/framework/security/provider/SmsAuthenticationProvider.java b/src/main/java/com/youlai/boot/auth/security/provider/SmsAuthenticationProvider.java similarity index 61% rename from src/main/java/com/youlai/boot/framework/security/provider/SmsAuthenticationProvider.java rename to src/main/java/com/youlai/boot/auth/security/provider/SmsAuthenticationProvider.java index 3450bd7a..68626416 100644 --- a/src/main/java/com/youlai/boot/framework/security/provider/SmsAuthenticationProvider.java +++ b/src/main/java/com/youlai/boot/auth/security/provider/SmsAuthenticationProvider.java @@ -1,13 +1,13 @@ -package com.youlai.boot.framework.security.provider; +package com.youlai.boot.auth.security.provider; import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; import com.youlai.boot.common.constant.RedisConstants; -import com.youlai.boot.framework.security.exception.SmsCaptchaException; -import com.youlai.boot.framework.security.model.SmsAuthenticationToken; -import com.youlai.boot.framework.security.model.SysUserDetails; -import com.youlai.boot.framework.security.model.UserAuthInfo; -import com.youlai.boot.system.service.UserService; +import com.youlai.boot.framework.security.model.SecurityUser; +import com.youlai.boot.framework.security.model.SecurityUserDetails; +import com.youlai.boot.framework.security.port.UserAuthenticationPort; +import com.youlai.boot.auth.security.exception.SmsCaptchaException; +import com.youlai.boot.auth.security.model.SmsAuthenticationToken; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.authentication.AuthenticationProvider; @@ -19,47 +19,34 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; /** * 短信验证码认证 Provider *

- * 实现 Spring Security 的 {@link AuthenticationProvider} 接口,处理短信验证码登录认证。 - *

* 认证流程: *

    *
  1. 根据手机号查询用户信息
  2. - *
  3. 校验用户状态(是否禁用)
  4. + *
  5. 校验用户状态
  6. *
  7. 校验短信验证码(与 Redis 缓存比对)
  8. - *
  9. 验证成功后删除验证码,防止重复使用
  10. + *
  11. 验证成功后删除验证码
  12. *
  13. 返回已认证的 Authentication
  14. *
* * @author Ray.Hao * @since 2.17.0 - * @see SmsAuthenticationToken - * @see AuthenticationProvider */ @Slf4j public class SmsAuthenticationProvider implements AuthenticationProvider { - private final UserService userService; - + private final UserAuthenticationPort userAuthPort; private final RedisTemplate redisTemplate; - public SmsAuthenticationProvider(UserService userService, RedisTemplate redisTemplate) { - this.userService = userService; + public SmsAuthenticationProvider(UserAuthenticationPort userAuthPort, RedisTemplate redisTemplate) { + this.userAuthPort = userAuthPort; this.redisTemplate = redisTemplate; } - /** - * 执行短信验证码认证 - * - * @param authentication 未认证的 {@link SmsAuthenticationToken} - * @return 已认证的 {@link SmsAuthenticationToken} - * @throws AuthenticationException 认证失败异常 - */ @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { String mobile = (String) authentication.getPrincipal(); String inputVerifyCode = (String) authentication.getCredentials(); - // 参数校验 if (StrUtil.isBlank(mobile)) { log.warn("短信验证码登录失败:手机号为空"); throw new SmsCaptchaException("手机号不能为空"); @@ -69,21 +56,18 @@ public class SmsAuthenticationProvider implements AuthenticationProvider { throw new SmsCaptchaException("验证码不能为空"); } - // 根据手机号获取用户信息 - UserAuthInfo userAuthInfo = userService.getAuthInfoByMobile(mobile); + SecurityUser securityUser = userAuthPort.getAuthInfoByMobile(mobile); - if (userAuthInfo == null) { + if (securityUser == null) { log.warn("短信验证码登录失败:用户不存在,手机号={}", mobile); throw new UsernameNotFoundException("用户不存在"); } - // 检查用户状态是否有效 - if (ObjectUtil.notEqual(userAuthInfo.getStatus(), 1)) { - log.warn("短信验证码登录失败:用户已禁用,用户名={}", userAuthInfo.getUsername()); + if (ObjectUtil.notEqual(securityUser.getStatus(), 1)) { + log.warn("短信验证码登录失败:用户已禁用,用户名={}", securityUser.getUsername()); throw new DisabledException("用户已被禁用"); } - // 校验短信验证码 String cacheKey = StrUtil.format(RedisConstants.Captcha.SMS_LOGIN_CODE, mobile); String cachedVerifyCode = (String) redisTemplate.opsForValue().get(cacheKey); @@ -97,24 +81,13 @@ public class SmsAuthenticationProvider implements AuthenticationProvider { throw new SmsCaptchaException("验证码错误"); } - // 验证成功后删除验证码,防止重复使用 redisTemplate.delete(cacheKey); - // 构建认证后的用户详情信息 - SysUserDetails userDetails = new SysUserDetails(userAuthInfo); - - log.info("短信验证码登录成功:用户名={},手机号={}", userAuthInfo.getUsername(), mobile); - - // 创建已认证的 SmsAuthenticationToken + SecurityUserDetails userDetails = new SecurityUserDetails(securityUser); + log.info("短信验证码登录成功:用户名={},手机号={}", securityUser.getUsername(), mobile); return SmsAuthenticationToken.authenticated(userDetails, userDetails.getAuthorities()); } - /** - * 支持的认证类型 - * - * @param authentication 认证类型 - * @return 是否支持该认证类型 - */ @Override public boolean supports(Class authentication) { return SmsAuthenticationToken.class.isAssignableFrom(authentication); diff --git a/src/main/java/com/youlai/boot/framework/security/provider/WxMaAuthenticationProvider.java b/src/main/java/com/youlai/boot/auth/security/provider/WxMaAuthenticationProvider.java similarity index 64% rename from src/main/java/com/youlai/boot/framework/security/provider/WxMaAuthenticationProvider.java rename to src/main/java/com/youlai/boot/auth/security/provider/WxMaAuthenticationProvider.java index d30bfd60..3b40f2f5 100644 --- a/src/main/java/com/youlai/boot/framework/security/provider/WxMaAuthenticationProvider.java +++ b/src/main/java/com/youlai/boot/auth/security/provider/WxMaAuthenticationProvider.java @@ -1,14 +1,16 @@ -package com.youlai.boot.framework.security.provider; +package com.youlai.boot.auth.security.provider; import cn.binarywang.wx.miniapp.api.WxMaService; import cn.binarywang.wx.miniapp.bean.WxMaJscode2SessionResult; import cn.hutool.core.util.ObjectUtil; -import com.youlai.boot.framework.security.exception.NeedBindMobileException; -import com.youlai.boot.framework.security.model.SysUserDetails; -import com.youlai.boot.framework.security.model.UserAuthInfo; -import com.youlai.boot.framework.security.model.WxMaAuthenticationToken; -import com.youlai.boot.framework.security.service.SysUserDetailsService; +import com.youlai.boot.common.enums.SocialPlatformEnum; +import com.youlai.boot.framework.security.model.SecurityUser; +import com.youlai.boot.framework.security.model.SecurityUserDetails; +import com.youlai.boot.framework.security.port.UserAuthenticationPort; +import com.youlai.boot.auth.security.exception.MobileNotBoundException; +import com.youlai.boot.auth.security.model.WxMaAuthenticationToken; import com.youlai.boot.system.model.entity.UserSocial; +import com.youlai.boot.system.service.UserSocialService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import me.chanjar.weixin.common.error.WxErrorException; @@ -26,7 +28,8 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; public class WxMaAuthenticationProvider implements AuthenticationProvider { private final WxMaService wxMaService; - private final SysUserDetailsService sysUserDetailsService; + private final UserAuthenticationPort userAuthPort; + private final UserSocialService userSocialService; @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { @@ -38,44 +41,35 @@ public class WxMaAuthenticationProvider implements AuthenticationProvider { } try { - // 1. 用 code 换取 openid WxMaJscode2SessionResult session = wxMaService.jsCode2SessionInfo(code); String openid = session.getOpenid(); String sessionKey = session.getSessionKey(); log.info("微信小程序登录:openid={}", openid); - // 2. 根据 openid 查询绑定信息 - UserSocial userSocial = sysUserDetailsService.getWechatMiniBindInfo(openid); + UserSocial userSocial = userSocialService.getByPlatformAndOpenid(SocialPlatformEnum.WECHAT_MINI, openid); if (userSocial == null) { - // 未绑定,抛出异常提示需要绑定手机号 log.info("微信小程序登录:用户未绑定手机号,openid={}", openid); - throw new NeedBindMobileException(openid, sessionKey); + throw new MobileNotBoundException(openid, sessionKey); } - // 3. 获取用户认证信息 - UserAuthInfo userAuthInfo = sysUserDetailsService.getAuthInfoByWechatOpenid(openid); + SecurityUser securityUser = userAuthPort.getAuthInfoByOpenid(SocialPlatformEnum.WECHAT_MINI, openid); - if (userAuthInfo == null) { + if (securityUser == null) { log.warn("微信小程序登录失败:用户不存在,openid={}", openid); throw new UsernameNotFoundException("用户不存在"); } - // 4. 检查用户状态 - if (ObjectUtil.notEqual(userAuthInfo.getStatus(), 1)) { - log.warn("微信小程序登录失败:用户已禁用,username={}", userAuthInfo.getUsername()); + if (ObjectUtil.notEqual(securityUser.getStatus(), 1)) { + log.warn("微信小程序登录失败:用户已禁用,username={}", securityUser.getUsername()); throw new DisabledException("用户已被禁用"); } - // 5. 更新 session_key - sysUserDetailsService.updateWechatSessionKey(userSocial.getId(), sessionKey); - - // 6. 构建已认证 Token - SysUserDetails userDetails = new SysUserDetails(userAuthInfo); - - log.info("微信小程序登录成功:username={}, openid={}", userAuthInfo.getUsername(), openid); + userSocialService.updateSessionKey(userSocial.getId(), sessionKey); + SecurityUserDetails userDetails = new SecurityUserDetails(securityUser); + log.info("微信小程序登录成功:username={}, openid={}", securityUser.getUsername(), openid); return WxMaAuthenticationToken.authenticated(userDetails, userDetails.getAuthorities()); } catch (WxErrorException e) { @@ -88,5 +82,4 @@ public class WxMaAuthenticationProvider implements AuthenticationProvider { public boolean supports(Class authentication) { return WxMaAuthenticationToken.class.isAssignableFrom(authentication); } - } diff --git a/src/main/java/com/youlai/boot/auth/service/impl/AuthServiceImpl.java b/src/main/java/com/youlai/boot/auth/service/impl/AuthServiceImpl.java index 086caa17..a2bc3c44 100644 --- a/src/main/java/com/youlai/boot/auth/service/impl/AuthServiceImpl.java +++ b/src/main/java/com/youlai/boot/auth/service/impl/AuthServiceImpl.java @@ -6,8 +6,8 @@ import com.youlai.boot.common.constant.RedisConstants; import com.youlai.boot.framework.captcha.model.CaptchaInfo; import com.youlai.boot.framework.captcha.service.CaptchaService; import com.youlai.boot.framework.security.model.AuthenticationToken; -import com.youlai.boot.framework.security.model.SmsAuthenticationToken; import com.youlai.boot.framework.security.token.TokenManager; +import com.youlai.boot.auth.security.model.SmsAuthenticationToken; import com.youlai.boot.framework.security.util.SecurityUtils; import com.youlai.boot.framework.integration.sms.enums.SmsTypeEnum; import com.youlai.boot.framework.integration.sms.service.SmsService; @@ -58,9 +58,9 @@ public class AuthServiceImpl implements AuthService { // 2. 执行认证(认证中) // 说明:这里的认证流程由 Spring Security 提供的 AuthenticationManager 执行。 // 默认情况下会委托给 DaoAuthenticationProvider: - // 1) retrieveUser(...):内部通过 UserDetailsService.loadUserByUsername(...) 获取用户信息(本项目为 SysUserDetailsService 实现) + // 1) retrieveUser(...):内部通过 UserDetailsService.loadUserByUsername(...) 获取用户信息(本项目为 SecurityUserDetailsService 实现) // 2) additionalAuthenticationChecks(...):对比请求密码与用户存储密码(由 PasswordEncoder 完成匹配) - // 认证通过后返回已认证的 Authentication(principal 为 SysUserDetails,authorities 为角色/权限集合)。 + // 认证通过后返回已认证的 Authentication(principal 为 SecurityUserDetails,authorities 为角色/权限集合)。 Authentication authentication = authenticationManager.authenticate(authenticationToken); // 3. 认证成功后生成 JWT 令牌,并存入 Security 上下文,供登录日志 AOP 使用(已认证) diff --git a/src/main/java/com/youlai/boot/auth/service/impl/WxMaAuthServiceImpl.java b/src/main/java/com/youlai/boot/auth/service/impl/WxMaAuthServiceImpl.java index 9a6e0aa6..439cb329 100644 --- a/src/main/java/com/youlai/boot/auth/service/impl/WxMaAuthServiceImpl.java +++ b/src/main/java/com/youlai/boot/auth/service/impl/WxMaAuthServiceImpl.java @@ -8,12 +8,12 @@ import cn.hutool.core.util.StrUtil; import com.youlai.boot.auth.model.vo.WxMaLoginVO; import com.youlai.boot.auth.service.WxMaAuthService; import com.youlai.boot.common.constant.RedisConstants; -import com.youlai.boot.framework.security.exception.NeedBindMobileException; import com.youlai.boot.framework.security.model.AuthenticationToken; -import com.youlai.boot.framework.security.model.SysUserDetails; -import com.youlai.boot.framework.security.model.WxMaAuthenticationToken; +import com.youlai.boot.framework.security.model.SecurityUserDetails; import com.youlai.boot.framework.security.token.TokenManager; -import com.youlai.boot.system.enums.SocialPlatformEnum; +import com.youlai.boot.auth.security.exception.MobileNotBoundException; +import com.youlai.boot.auth.security.model.WxMaAuthenticationToken; +import com.youlai.boot.common.enums.SocialPlatformEnum; import com.youlai.boot.system.model.entity.SysUser; import com.youlai.boot.system.service.UserSocialService; import com.youlai.boot.system.service.UserService; @@ -69,7 +69,7 @@ public class WxMaAuthServiceImpl implements WxMaAuthService { .tokenType(authToken.getTokenType()) .expiresIn(authToken.getExpiresIn()) .build(); - } catch (NeedBindMobileException e) { + } catch (MobileNotBoundException e) { return WxMaLoginVO.builder() .isNewUser(true) .needBindMobile(true) @@ -234,7 +234,7 @@ public class WxMaAuthServiceImpl implements WxMaAuthService { * 生成认证令牌 */ private AuthenticationToken generateAuthToken(String mobile) { - SysUserDetails userDetails = new SysUserDetails(userService.getAuthInfoByMobile(mobile)); + SecurityUserDetails userDetails = new SecurityUserDetails(userService.getAuthInfoByMobile(mobile)); Authentication authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities() ); diff --git a/src/main/java/com/youlai/boot/codegen/controller/CodegenController.java b/src/main/java/com/youlai/boot/codegen/controller/CodegenController.java index 7e589f1a..e9746d53 100644 --- a/src/main/java/com/youlai/boot/codegen/controller/CodegenController.java +++ b/src/main/java/com/youlai/boot/codegen/controller/CodegenController.java @@ -11,7 +11,7 @@ import com.youlai.boot.codegen.model.form.GenConfigForm; import com.youlai.boot.codegen.model.query.TableQuery; import com.youlai.boot.codegen.model.vo.CodegenPreviewVO; import com.youlai.boot.codegen.model.vo.TablePageVO; -import com.youlai.boot.common.annotation.Log; +import com.youlai.boot.framework.annotation.Log; import com.youlai.boot.codegen.service.GenTableService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; diff --git a/src/main/java/com/youlai/boot/codegen/model/entity/GenTable.java b/src/main/java/com/youlai/boot/codegen/model/entity/GenTable.java index 51f5f289..188bbd26 100644 --- a/src/main/java/com/youlai/boot/codegen/model/entity/GenTable.java +++ b/src/main/java/com/youlai/boot/codegen/model/entity/GenTable.java @@ -2,7 +2,7 @@ package com.youlai.boot.codegen.model.entity; import com.baomidou.mybatisplus.annotation.*; -import com.youlai.boot.common.base.BaseEntity; +import com.youlai.boot.framework.base.BaseEntity; import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/com/youlai/boot/codegen/model/entity/GenTableColumn.java b/src/main/java/com/youlai/boot/codegen/model/entity/GenTableColumn.java index a2640747..fdebd6d4 100644 --- a/src/main/java/com/youlai/boot/codegen/model/entity/GenTableColumn.java +++ b/src/main/java/com/youlai/boot/codegen/model/entity/GenTableColumn.java @@ -4,7 +4,7 @@ import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import com.fasterxml.jackson.annotation.JsonIgnore; -import com.youlai.boot.common.base.BaseEntity; +import com.youlai.boot.framework.base.BaseEntity; import com.youlai.boot.codegen.enums.FormTypeEnum; import com.youlai.boot.codegen.enums.QueryTypeEnum; import lombok.Getter; diff --git a/src/main/java/com/youlai/boot/common/annotation/RateLimit.java b/src/main/java/com/youlai/boot/common/annotation/RateLimit.java deleted file mode 100644 index f53893bc..00000000 --- a/src/main/java/com/youlai/boot/common/annotation/RateLimit.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.youlai.boot.common.annotation; - -import java.lang.annotation.*; -import java.util.concurrent.TimeUnit; - -/** - * 接口限流 - *

标注在 Controller 方法上,基于 Redis 计数窗口实现

- * - * @author Ray.Hao - * @since 4.3.1 - */ -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -@Documented -public @interface RateLimit { - - int limit() default 0; - - int window() default 0; - - TimeUnit timeUnit() default TimeUnit.SECONDS; - - String prefix() default "rate_limit:"; -} diff --git a/src/main/java/com/youlai/boot/common/base/BaseQuery.java b/src/main/java/com/youlai/boot/common/base/BaseQuery.java index 7548937d..40b6a78e 100644 --- a/src/main/java/com/youlai/boot/common/base/BaseQuery.java +++ b/src/main/java/com/youlai/boot/common/base/BaseQuery.java @@ -1,6 +1,6 @@ package com.youlai.boot.common.base; -import com.youlai.boot.common.annotation.ValidField; +import com.youlai.boot.framework.annotation.ValidField; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; diff --git a/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java b/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java index 9aaadcda..61be1d98 100644 --- a/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java +++ b/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java @@ -33,9 +33,9 @@ public interface JwtClaimConstants { String DATA_SCOPES = "dataScopes"; /** - * 权限(角色Code)集合 + * 角色编码集合(不带 ROLE_ 前缀) */ - String AUTHORITIES = "authorities"; + String ROLES = "roles"; /** * Token 版本号 diff --git a/src/main/java/com/youlai/boot/common/constant/RedisConstants.java b/src/main/java/com/youlai/boot/common/constant/RedisConstants.java index 035d6792..64c9f805 100644 --- a/src/main/java/com/youlai/boot/common/constant/RedisConstants.java +++ b/src/main/java/com/youlai/boot/common/constant/RedisConstants.java @@ -11,12 +11,12 @@ public interface RedisConstants { /** * 限流相关键 */ - interface RateLimiter { - /** 接口级限流 Key(示例:login:rate_limit:token:/api/v1/auth/login) */ - String API = "{}rate_limit:{}:{}"; + interface RateLimit { + /** 接口级限流 Key(示例:rate_limit:api:{user}:{uri}) */ + String API = "rate_limit:{}:{}:{}"; - /** IP 全局限流 Key(示例:rate_limiter:ip:192.168.1.100) */ - String IP = "rate_limiter:ip:{}"; + /** IP 全局限流 Key(示例:rate_limit:ip:192.168.1.100) */ + String IP = "rate_limit:ip:{}"; } /** @@ -43,6 +43,8 @@ public interface RedisConstants { String REVOKED_JTI = BLACKLIST_TOKEN; // 用户 Token 版本号(用于按用户失效历史 JWT):token.tokenVersion != redis.tokenVersion => token 无效 String USER_TOKEN_VERSION = "auth:user:token_version:{}"; + // 扫码登录票据上下文(ticket -> QrCodeLoginContext) + String QR_CODE_LOGIN = "auth:qr_code:{}"; } /** diff --git a/src/main/java/com/youlai/boot/system/enums/SocialPlatformEnum.java b/src/main/java/com/youlai/boot/common/enums/SocialPlatformEnum.java similarity index 94% rename from src/main/java/com/youlai/boot/system/enums/SocialPlatformEnum.java rename to src/main/java/com/youlai/boot/common/enums/SocialPlatformEnum.java index 0779f594..c6e85c13 100644 --- a/src/main/java/com/youlai/boot/system/enums/SocialPlatformEnum.java +++ b/src/main/java/com/youlai/boot/common/enums/SocialPlatformEnum.java @@ -1,4 +1,4 @@ -package com.youlai.boot.system.enums; +package com.youlai.boot.common.enums; import com.baomidou.mybatisplus.annotation.EnumValue; import com.youlai.boot.common.base.IBaseEnum; diff --git a/src/main/java/com/youlai/boot/common/result/ResultCode.java b/src/main/java/com/youlai/boot/common/result/ResultCode.java index af2ebb8a..31169976 100644 --- a/src/main/java/com/youlai/boot/common/result/ResultCode.java +++ b/src/main/java/com/youlai/boot/common/result/ResultCode.java @@ -80,6 +80,12 @@ public enum ResultCode implements IResultCode, Serializable { USER_VERIFICATION_CODE_ATTEMPT_LIMIT_EXCEEDED("A0241", "用户验证码尝试次数超限"), USER_VERIFICATION_CODE_EXPIRED("A0242", "用户验证码过期"), + /** A025x:扫码登录异常 */ + QR_CODE_NOT_FOUND("A0250", "扫码登录票据不存在或已过期"), + QR_CODE_STATUS_ILLEGAL("A0251", "当前扫码状态不允许该操作"), + QR_CODE_USER_MISMATCH("A0252", "操作用户与扫码用户不一致"), + QR_CODE_ALREADY_USED("A0253", "扫码登录票据已被使用"), + /** A03xx:访问权限异常 */ ACCESS_PERMISSION_EXCEPTION("A0300", "访问权限异常"), ACCESS_UNAUTHORIZED("A0301", "访问未授权"), diff --git a/src/main/java/com/youlai/boot/common/validator/FieldValidator.java b/src/main/java/com/youlai/boot/common/validator/FieldValidator.java index 200e67c8..9659627f 100644 --- a/src/main/java/com/youlai/boot/common/validator/FieldValidator.java +++ b/src/main/java/com/youlai/boot/common/validator/FieldValidator.java @@ -1,6 +1,6 @@ package com.youlai.boot.common.validator; -import com.youlai.boot.common.annotation.ValidField; +import com.youlai.boot.framework.annotation.ValidField; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; diff --git a/src/main/java/com/youlai/boot/device/controller/ContactController.java b/src/main/java/com/youlai/boot/device/controller/ContactController.java index 7b282250..4956421f 100644 --- a/src/main/java/com/youlai/boot/device/controller/ContactController.java +++ b/src/main/java/com/youlai/boot/device/controller/ContactController.java @@ -1,6 +1,6 @@ package com.youlai.boot.device.controller; -import com.youlai.boot.common.annotation.Log; +import com.youlai.boot.framework.annotation.Log; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; import com.youlai.boot.common.result.Result; diff --git a/src/main/java/com/youlai/boot/device/controller/DeviceController.java b/src/main/java/com/youlai/boot/device/controller/DeviceController.java index 48c27d2c..3d2db2fe 100644 --- a/src/main/java/com/youlai/boot/device/controller/DeviceController.java +++ b/src/main/java/com/youlai/boot/device/controller/DeviceController.java @@ -1,6 +1,6 @@ package com.youlai.boot.device.controller; -import com.youlai.boot.common.annotation.Log; +import com.youlai.boot.framework.annotation.Log; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; import com.youlai.boot.common.result.PageResult; diff --git a/src/main/java/com/youlai/boot/device/controller/MobileController.java b/src/main/java/com/youlai/boot/device/controller/MobileController.java index 775eb735..e35ad4fd 100644 --- a/src/main/java/com/youlai/boot/device/controller/MobileController.java +++ b/src/main/java/com/youlai/boot/device/controller/MobileController.java @@ -1,7 +1,7 @@ package com.youlai.boot.device.controller; import cn.hutool.core.util.IdUtil; -import com.youlai.boot.common.annotation.Log; +import com.youlai.boot.framework.annotation.Log; import com.youlai.boot.common.config.FilePath; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; diff --git a/src/main/java/com/youlai/boot/device/mapper/DeviceMapper.java b/src/main/java/com/youlai/boot/device/mapper/DeviceMapper.java index 3db11714..10c79257 100644 --- a/src/main/java/com/youlai/boot/device/mapper/DeviceMapper.java +++ b/src/main/java/com/youlai/boot/device/mapper/DeviceMapper.java @@ -2,7 +2,7 @@ package com.youlai.boot.device.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.youlai.boot.common.annotation.DataPermission; +import com.youlai.boot.framework.annotation.DataPermission; import com.youlai.boot.device.model.entity.SnDeviceInfo; import com.youlai.boot.device.model.query.DeviceQuery; import com.youlai.boot.device.model.vo.DevicePageVO; diff --git a/src/main/java/com/youlai/boot/device/model/entity/SnContact.java b/src/main/java/com/youlai/boot/device/model/entity/SnContact.java index 7e17502b..142b4419 100644 --- a/src/main/java/com/youlai/boot/device/model/entity/SnContact.java +++ b/src/main/java/com/youlai/boot/device/model/entity/SnContact.java @@ -2,7 +2,7 @@ package com.youlai.boot.device.model.entity; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; -import com.youlai.boot.common.base.BaseEntity; +import com.youlai.boot.framework.base.BaseEntity; import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/com/youlai/boot/device/model/entity/SnDeveloper.java b/src/main/java/com/youlai/boot/device/model/entity/SnDeveloper.java index 034bbdb3..123c3e92 100644 --- a/src/main/java/com/youlai/boot/device/model/entity/SnDeveloper.java +++ b/src/main/java/com/youlai/boot/device/model/entity/SnDeveloper.java @@ -1,7 +1,7 @@ package com.youlai.boot.device.model.entity; import com.baomidou.mybatisplus.annotation.TableName; -import com.youlai.boot.common.base.BaseEntity; +import com.youlai.boot.framework.base.BaseEntity; import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/com/youlai/boot/device/model/entity/SnDevice.java b/src/main/java/com/youlai/boot/device/model/entity/SnDevice.java index d756f82e..f56e4743 100644 --- a/src/main/java/com/youlai/boot/device/model/entity/SnDevice.java +++ b/src/main/java/com/youlai/boot/device/model/entity/SnDevice.java @@ -1,7 +1,7 @@ package com.youlai.boot.device.model.entity; import com.baomidou.mybatisplus.annotation.TableName; -import com.youlai.boot.common.base.BaseEntity; +import com.youlai.boot.framework.base.BaseEntity; import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/com/youlai/boot/device/model/entity/SnDeviceHardware.java b/src/main/java/com/youlai/boot/device/model/entity/SnDeviceHardware.java index d3264da6..5e99d235 100644 --- a/src/main/java/com/youlai/boot/device/model/entity/SnDeviceHardware.java +++ b/src/main/java/com/youlai/boot/device/model/entity/SnDeviceHardware.java @@ -1,7 +1,7 @@ package com.youlai.boot.device.model.entity; import com.baomidou.mybatisplus.annotation.TableName; -import com.youlai.boot.common.base.BaseEntity; +import com.youlai.boot.framework.base.BaseEntity; import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/com/youlai/boot/device/model/entity/SnDeviceInfo.java b/src/main/java/com/youlai/boot/device/model/entity/SnDeviceInfo.java index b3fb2a45..fe3ac2aa 100644 --- a/src/main/java/com/youlai/boot/device/model/entity/SnDeviceInfo.java +++ b/src/main/java/com/youlai/boot/device/model/entity/SnDeviceInfo.java @@ -1,7 +1,7 @@ package com.youlai.boot.device.model.entity; import com.baomidou.mybatisplus.annotation.TableName; -import com.youlai.boot.common.base.BaseEntity; +import com.youlai.boot.framework.base.BaseEntity; import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/com/youlai/boot/device/model/entity/SnLocation.java b/src/main/java/com/youlai/boot/device/model/entity/SnLocation.java index d6a09a61..6f8ffc25 100644 --- a/src/main/java/com/youlai/boot/device/model/entity/SnLocation.java +++ b/src/main/java/com/youlai/boot/device/model/entity/SnLocation.java @@ -2,7 +2,7 @@ package com.youlai.boot.device.model.entity; import com.baomidou.mybatisplus.annotation.TableName; -import com.youlai.boot.common.base.BaseEntity; +import com.youlai.boot.framework.base.BaseEntity; import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/com/youlai/boot/device/model/entity/SnScreenshot.java b/src/main/java/com/youlai/boot/device/model/entity/SnScreenshot.java index a9b68324..114fa8c1 100644 --- a/src/main/java/com/youlai/boot/device/model/entity/SnScreenshot.java +++ b/src/main/java/com/youlai/boot/device/model/entity/SnScreenshot.java @@ -1,7 +1,7 @@ package com.youlai.boot.device.model.entity; import com.baomidou.mybatisplus.annotation.TableName; -import com.youlai.boot.common.base.BaseEntity; +import com.youlai.boot.framework.base.BaseEntity; import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/com/youlai/boot/file/config/AliyunOssProperties.java b/src/main/java/com/youlai/boot/file/config/AliyunOssProperties.java deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/com/youlai/boot/file/config/FileStorageProperties.java b/src/main/java/com/youlai/boot/file/config/FileStorageProperties.java new file mode 100644 index 00000000..e00f199e --- /dev/null +++ b/src/main/java/com/youlai/boot/file/config/FileStorageProperties.java @@ -0,0 +1,52 @@ +package com.youlai.boot.file.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 文件存储顶层配置:存储类型与上传限制。 + *

+ * minio / aliyun / local 子树由各自 {@code FileService} 实现类绑定, + * 此处仅承载 {@code type} 与 {@code upload}。 + */ +@Data +@Component +@ConfigurationProperties(prefix = "file-storage") +public class FileStorageProperties { + + /** 存储类型:minio | aliyun | local */ + private String type; + + private Upload upload = new Upload(); + + @Data + public static class Upload { + + /** 单文件大小上限,如 50MB;同时作为 spring.servlet.multipart 的上限 */ + private String maxFileSize; + + /** 允许的文件扩展名白名单(置空表示不限制) */ + private List allowedExtensions; + } + + /** + * 允许扩展名集合(小写、不含点);空集合表示不限制。 + */ + public Set getAllowedExtensions() { + if (upload == null || upload.allowedExtensions == null) { + return Collections.emptySet(); + } + return upload.allowedExtensions.stream() + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(s -> s.startsWith(".") ? s.substring(1) : s) + .map(String::toLowerCase) + .collect(Collectors.toUnmodifiableSet()); + } +} diff --git a/src/main/java/com/youlai/boot/file/config/LocalFileProperties.java b/src/main/java/com/youlai/boot/file/config/LocalFileProperties.java deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/com/youlai/boot/file/config/MinioProperties.java b/src/main/java/com/youlai/boot/file/config/MinioProperties.java deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/com/youlai/boot/file/controller/FileController.java b/src/main/java/com/youlai/boot/file/controller/FileController.java index 0975f8c7..154d981b 100644 --- a/src/main/java/com/youlai/boot/file/controller/FileController.java +++ b/src/main/java/com/youlai/boot/file/controller/FileController.java @@ -1,8 +1,13 @@ package com.youlai.boot.file.controller; +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.StrUtil; +import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.common.result.Result; -import com.youlai.boot.file.service.FileService; +import com.youlai.boot.common.result.ResultCode; +import com.youlai.boot.file.config.FileStorageProperties; import com.youlai.boot.file.model.FileInfo; +import com.youlai.boot.file.service.FileService; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Schema; @@ -13,6 +18,8 @@ import lombok.SneakyThrows; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import java.util.Set; + /** * 文件控制层 * @@ -26,6 +33,7 @@ import org.springframework.web.multipart.MultipartFile; public class FileController { private final FileService fileService; + private final FileStorageProperties fileStorageProperties; @PostMapping @Operation(summary = "文件上传") @@ -39,6 +47,7 @@ public class FileController { ) @RequestPart(value = "file") MultipartFile file ) { + validateFileExtension(file); FileInfo fileInfo = fileService.uploadFile(file); return Result.success(fileInfo); } @@ -52,4 +61,19 @@ public class FileController { boolean result = fileService.deleteFile(filePath); return Result.judge(result); } + + /** + * 文件扩展名白名单校验:{@code allowed-extensions} 为空时不限制。 + */ + private void validateFileExtension(MultipartFile file) { + Set allowedExtensions = fileStorageProperties.getAllowedExtensions(); + if (allowedExtensions.isEmpty()) { + return; + } + String suffix = FileUtil.getSuffix(file.getOriginalFilename()); + if (StrUtil.isBlank(suffix) || !allowedExtensions.contains(suffix.toLowerCase())) { + throw new BusinessException(ResultCode.UPLOAD_FILE_EXCEPTION, + "不支持的文件类型: " + (StrUtil.isBlank(suffix) ? "" : "." + suffix)); + } + } } diff --git a/src/main/java/com/youlai/boot/file/service/impl/AliyunFileServiceImpl.java b/src/main/java/com/youlai/boot/file/service/impl/AliyunFileServiceImpl.java index 55b6ee90..6d4e93e0 100644 --- a/src/main/java/com/youlai/boot/file/service/impl/AliyunFileServiceImpl.java +++ b/src/main/java/com/youlai/boot/file/service/impl/AliyunFileServiceImpl.java @@ -23,8 +23,8 @@ import java.io.InputStream; import java.time.LocalDateTime; @Component -@ConditionalOnProperty(value = "oss.type", havingValue = "aliyun") -@ConfigurationProperties(prefix = "oss.aliyun") +@ConditionalOnProperty(value = "file-storage.type", havingValue = "aliyun") +@ConfigurationProperties(prefix = "file-storage.aliyun") @RequiredArgsConstructor @Data public class AliyunFileServiceImpl implements FileService { @@ -32,7 +32,7 @@ public class AliyunFileServiceImpl implements FileService { private String endpoint; private String accessKeyId; private String accessKeySecret; - private String bucketName; + private String bucket; private OSS aliyunOssClient; @@ -51,12 +51,12 @@ public class AliyunFileServiceImpl implements FileService { try (InputStream inputStream = file.getInputStream()) { ObjectMetadata metadata = new ObjectMetadata(); metadata.setContentType(file.getContentType()); - PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, fileName, inputStream, metadata); + PutObjectRequest putObjectRequest = new PutObjectRequest(bucket, fileName, inputStream, metadata); aliyunOssClient.putObject(putObjectRequest); } catch (Exception e) { throw new RuntimeException("文件上传失败"); } - String fileUrl = "https://" + bucketName + "." + endpoint + "/" + fileName; + String fileUrl = "https://" + bucket + "." + endpoint + "/" + fileName; FileInfo fileInfo = new FileInfo(); fileInfo.setName(originalFilename); fileInfo.setUrl(fileUrl); @@ -66,9 +66,9 @@ public class AliyunFileServiceImpl implements FileService { @Override public boolean deleteFile(String filePath) { Assert.notBlank(filePath, "删除文件路径不能为空"); - String fileHost = "https://" + bucketName + "." + endpoint; + String fileHost = "https://" + bucket + "." + endpoint; String fileName = filePath.substring(fileHost.length() + 1); - aliyunOssClient.deleteObject(bucketName, fileName); + aliyunOssClient.deleteObject(bucket, fileName); return true; } } diff --git a/src/main/java/com/youlai/boot/file/service/impl/LocalFileServiceImpl.java b/src/main/java/com/youlai/boot/file/service/impl/LocalFileServiceImpl.java index 555408e6..4905625d 100644 --- a/src/main/java/com/youlai/boot/file/service/impl/LocalFileServiceImpl.java +++ b/src/main/java/com/youlai/boot/file/service/impl/LocalFileServiceImpl.java @@ -22,13 +22,13 @@ import java.time.LocalDateTime; @Data @Slf4j @Component -@ConditionalOnProperty(value = "oss.type", havingValue = "local") -@ConfigurationProperties(prefix = "oss.local") +@ConditionalOnProperty(value = "file-storage.type", havingValue = "local") +@ConfigurationProperties(prefix = "file-storage.local") @RequiredArgsConstructor public class LocalFileServiceImpl implements FileService { - @Value("${oss.local.storage-path}") - private String storagePath; + @Value("${file-storage.local.path}") + private String path; @Override public FileInfo uploadFile(MultipartFile file) { @@ -36,7 +36,7 @@ public class LocalFileServiceImpl implements FileService { String suffix = FileUtil.getSuffix(originalFilename); String fileName = IdUtil.simpleUUID()+ "." + suffix;; String folder = DateUtil.format(LocalDateTime.now(), DatePattern.PURE_DATE_PATTERN); - String filePrefix = storagePath.endsWith(File.separator) ? storagePath : storagePath + File.separator; + String filePrefix = path.endsWith(File.separator) ? path : path + File.separator; try (InputStream inputStream = file.getInputStream()) { FileUtil.writeFromStream(inputStream, filePrefix + folder + File.separator + fileName); } catch (Exception e) { @@ -55,9 +55,9 @@ public class LocalFileServiceImpl implements FileService { if (filePath == null || filePath.isEmpty()) { return false; } - if (FileUtil.isDirectory(storagePath + filePath)) { + if (FileUtil.isDirectory(path + filePath)) { return false; } - return FileUtil.del(storagePath + filePath); + return FileUtil.del(path + filePath); } } diff --git a/src/main/java/com/youlai/boot/file/service/impl/MinioFileServiceImpl.java b/src/main/java/com/youlai/boot/file/service/impl/MinioFileServiceImpl.java index 3a0d6caa..b69a2acb 100644 --- a/src/main/java/com/youlai/boot/file/service/impl/MinioFileServiceImpl.java +++ b/src/main/java/com/youlai/boot/file/service/impl/MinioFileServiceImpl.java @@ -25,8 +25,8 @@ import java.io.InputStream; import java.time.LocalDateTime; @Component -@ConditionalOnProperty(value = "oss.type", havingValue = "minio") -@ConfigurationProperties(prefix = "oss.minio") +@ConditionalOnProperty(value = "file-storage.type", havingValue = "minio") +@ConfigurationProperties(prefix = "file-storage.minio") @RequiredArgsConstructor @Data @Slf4j @@ -35,8 +35,8 @@ public class MinioFileServiceImpl implements FileService { private String endpoint; private String accessKey; private String secretKey; - private String bucketName; - private String customDomain; + private String bucket; + private String domain; private MinioClient minioClient; @@ -50,7 +50,7 @@ public class MinioFileServiceImpl implements FileService { @Override public FileInfo uploadFile(MultipartFile file) { - createBucketIfAbsent(bucketName); + createBucketIfAbsent(bucket); String originalFilename = file.getOriginalFilename(); String suffix = FileUtil.getSuffix(originalFilename); String dateFolder = DateUtil.format(LocalDateTime.now(), "yyyyMMdd"); @@ -58,7 +58,7 @@ public class MinioFileServiceImpl implements FileService { try (InputStream inputStream = file.getInputStream()) { PutObjectArgs putObjectArgs = PutObjectArgs.builder() - .bucket(bucketName) + .bucket(bucket) .object(dateFolder + "/"+ fileName) .contentType(file.getContentType()) .stream(inputStream, inputStream.available(), -1) @@ -66,16 +66,16 @@ public class MinioFileServiceImpl implements FileService { minioClient.putObject(putObjectArgs); String fileUrl; - if (StrUtil.isBlank(customDomain)) { + if (StrUtil.isBlank(domain)) { GetPresignedObjectUrlArgs getPresignedObjectUrlArgs = GetPresignedObjectUrlArgs.builder() - .bucket(bucketName) + .bucket(bucket) .object(dateFolder + "/"+ fileName) .method(Method.GET) .build(); fileUrl = minioClient.getPresignedObjectUrl(getPresignedObjectUrlArgs); fileUrl = fileUrl.substring(0, fileUrl.indexOf("?")); } else { - fileUrl = customDomain + "/"+ bucketName + "/"+ dateFolder + "/"+ fileName; + fileUrl = domain + "/"+ bucket + "/"+ dateFolder + "/"+ fileName; } FileInfo fileInfo = new FileInfo(); @@ -93,13 +93,13 @@ public class MinioFileServiceImpl implements FileService { Assert.notBlank(filePath, "删除文件路径不能为空"); try { String fileName; - if (StrUtil.isNotBlank(customDomain)) { - fileName = filePath.substring(customDomain.length() + 1 + bucketName.length() + 1); + if (StrUtil.isNotBlank(domain)) { + fileName = filePath.substring(domain.length() + 1 + bucket.length() + 1); } else { - fileName = filePath.substring(endpoint.length() + 1 + bucketName.length() + 1); + fileName = filePath.substring(endpoint.length() + 1 + bucket.length() + 1); } minioClient.removeObject(RemoveObjectArgs.builder() - .bucket(bucketName).object(fileName).build()); + .bucket(bucket).object(fileName).build()); return true; } catch (Exception e) { log.error("删除文件失败", e); @@ -107,23 +107,23 @@ public class MinioFileServiceImpl implements FileService { } } - private static String publicBucketPolicy(String bucketName) { + private static String publicBucketPolicy(String bucket) { return "{\"Version\":\"2012-10-17\"," + "\"Statement\":[{\"Effect\":\"Allow\"," + "\"Principal\":{\"AWS\":[\"*\"]}," + "\"Action\":[\"s3:ListBucketMultipartUploads\",\"s3:GetBucketLocation\",\"s3:ListBucket\"]," - + "\"Resource\":[\"arn:aws:s3:::" + bucketName + "\"]}," + + "\"Resource\":[\"arn:aws:s3:::" + bucket + "\"]}," + "{\"Effect\":\"Allow\"," + "\"Principal\":{\"AWS\":[\"*\"]}," + "\"Action\":[\"s3:ListMultipartUploadParts\",\"s3:PutObject\",\"s3:AbortMultipartUpload\",\"s3:DeleteObject\",\"s3:GetObject\"]," - + "\"Resource\":[\"arn:aws:s3:::" + bucketName + "/*\"]}]}"; + + "\"Resource\":[\"arn:aws:s3:::" + bucket + "/*\"]}]}"; } @SneakyThrows - private void createBucketIfAbsent(String bucketName) { - if (!minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build())) { - minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build()); + private void createBucketIfAbsent(String bucket) { + if (!minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build())) { + minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build()); minioClient.setBucketPolicy(SetBucketPolicyArgs.builder() - .bucket(bucketName).config(publicBucketPolicy(bucketName)).build()); + .bucket(bucket).config(publicBucketPolicy(bucket)).build()); } } } diff --git a/src/main/java/com/youlai/boot/common/annotation/DataPermission.java b/src/main/java/com/youlai/boot/framework/annotation/DataPermission.java similarity index 91% rename from src/main/java/com/youlai/boot/common/annotation/DataPermission.java rename to src/main/java/com/youlai/boot/framework/annotation/DataPermission.java index f63832c8..d3fd0b4c 100644 --- a/src/main/java/com/youlai/boot/common/annotation/DataPermission.java +++ b/src/main/java/com/youlai/boot/framework/annotation/DataPermission.java @@ -1,4 +1,4 @@ -package com.youlai.boot.common.annotation; +package com.youlai.boot.framework.annotation; import java.lang.annotation.*; @@ -25,4 +25,3 @@ public @interface DataPermission { String userIdColumnName() default "create_by"; } - diff --git a/src/main/java/com/youlai/boot/common/annotation/Log.java b/src/main/java/com/youlai/boot/framework/annotation/Log.java similarity index 95% rename from src/main/java/com/youlai/boot/common/annotation/Log.java rename to src/main/java/com/youlai/boot/framework/annotation/Log.java index c02d5608..16e9ec06 100644 --- a/src/main/java/com/youlai/boot/common/annotation/Log.java +++ b/src/main/java/com/youlai/boot/framework/annotation/Log.java @@ -1,4 +1,4 @@ -package com.youlai.boot.common.annotation; +package com.youlai.boot.framework.annotation; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; diff --git a/src/main/java/com/youlai/boot/framework/annotation/RateLimit.java b/src/main/java/com/youlai/boot/framework/annotation/RateLimit.java new file mode 100644 index 00000000..1605d977 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/annotation/RateLimit.java @@ -0,0 +1,30 @@ +package com.youlai.boot.framework.annotation; + +import java.lang.annotation.*; + +/** + * 接口限流 + *

标注在 Controller 方法上,基于 Redis 滑动窗口实现

+ * + * @author Ray.Hao + * @since 4.3.1 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RateLimit { + + /** + * 窗口内允许的最大请求数。 + *

+ * {@code <=0} 时使用全局默认值 {@code rate-limit.default-limit}。 + */ + int limit() default 0; + + /** + * 滑动窗口大小(秒)。 + *

+ * {@code <=0} 时使用全局默认值 {@code rate-limit.default-window}。 + */ + int window() default 0; +} diff --git a/src/main/java/com/youlai/boot/common/annotation/RepeatSubmit.java b/src/main/java/com/youlai/boot/framework/annotation/RepeatSubmit.java similarity index 90% rename from src/main/java/com/youlai/boot/common/annotation/RepeatSubmit.java rename to src/main/java/com/youlai/boot/framework/annotation/RepeatSubmit.java index 053f1320..f489a982 100644 --- a/src/main/java/com/youlai/boot/common/annotation/RepeatSubmit.java +++ b/src/main/java/com/youlai/boot/framework/annotation/RepeatSubmit.java @@ -1,4 +1,4 @@ -package com.youlai.boot.common.annotation; +package com.youlai.boot.framework.annotation; import java.lang.annotation.*; diff --git a/src/main/java/com/youlai/boot/common/annotation/ValidField.java b/src/main/java/com/youlai/boot/framework/annotation/ValidField.java similarity index 93% rename from src/main/java/com/youlai/boot/common/annotation/ValidField.java rename to src/main/java/com/youlai/boot/framework/annotation/ValidField.java index 559b06e1..b7be969d 100644 --- a/src/main/java/com/youlai/boot/common/annotation/ValidField.java +++ b/src/main/java/com/youlai/boot/framework/annotation/ValidField.java @@ -1,4 +1,4 @@ -package com.youlai.boot.common.annotation; +package com.youlai.boot.framework.annotation; import com.youlai.boot.common.validator.FieldValidator; import jakarta.validation.Constraint; diff --git a/src/main/java/com/youlai/boot/common/base/BaseEntity.java b/src/main/java/com/youlai/boot/framework/base/BaseEntity.java similarity index 76% rename from src/main/java/com/youlai/boot/common/base/BaseEntity.java rename to src/main/java/com/youlai/boot/framework/base/BaseEntity.java index 70cd1a92..7b3fb9b5 100644 --- a/src/main/java/com/youlai/boot/common/base/BaseEntity.java +++ b/src/main/java/com/youlai/boot/framework/base/BaseEntity.java @@ -1,12 +1,11 @@ -package com.youlai.boot.common.base; +package com.youlai.boot.framework.base; import com.baomidou.mybatisplus.annotation.FieldFill; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; -import com.fasterxml.jackson.annotation.JsonFormat; -import com.fasterxml.jackson.annotation.JsonInclude; import lombok.Data; +import com.fasterxml.jackson.annotation.JsonInclude; import java.io.Serial; import java.io.Serializable; @@ -15,7 +14,8 @@ import java.time.LocalDateTime; /** * 基础实体类 * - *

实体类的基类,包含了实体类的公共属性,如创建时间、更新时间、逻辑删除标识等

+ *

包含主键和创建/更新时间,适用于所有实体(含关联表)

+ *

审计字段(createBy/updateBy)和逻辑删除字段(isDeleted)由需要的实体自行声明

* * @author Ray.Hao * @since 4.3.1 @@ -37,7 +37,6 @@ public class BaseEntity implements Serializable { */ @TableField(fill = FieldFill.INSERT) @JsonInclude(value = JsonInclude.Include.NON_NULL) - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTime; /** @@ -45,7 +44,6 @@ public class BaseEntity implements Serializable { */ @TableField(fill = FieldFill.INSERT_UPDATE) @JsonInclude(value = JsonInclude.Include.NON_NULL) - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime updateTime; } diff --git a/src/main/java/com/youlai/boot/framework/cache/RedisConfig.java b/src/main/java/com/youlai/boot/framework/cache/RedisConfig.java index 610380d9..8014f69f 100644 --- a/src/main/java/com/youlai/boot/framework/cache/RedisConfig.java +++ b/src/main/java/com/youlai/boot/framework/cache/RedisConfig.java @@ -24,12 +24,10 @@ public class RedisConfig { * 修改 Redis 序列化方式,默认 JdkSerializationRedisSerializer * * @param redisConnectionFactory {@link RedisConnectionFactory} - * @param jsonMapper Jackson 序列化器(统一使用不写入类型信息的配置) * @return {@link RedisTemplate} */ @Bean - public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory, - JsonMapper jsonMapper) { + public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { RedisTemplate redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(redisConnectionFactory); @@ -38,7 +36,9 @@ public class RedisConfig { redisTemplate.setKeySerializer(RedisSerializer.string()); redisTemplate.setHashKeySerializer(RedisSerializer.string()); - // Value 使用自定义 JSON 序列化(不写入类型信息,避免 HashSet 等集合被序列化成带 @class 的结构) + JsonMapper jsonMapper = JsonMapper.builder() + .disable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS) + .build(); JacksonJsonRedisSerializer jsonSerializer = new JacksonJsonRedisSerializer<>(jsonMapper, Object.class); redisTemplate.setValueSerializer(jsonSerializer); @@ -48,17 +48,4 @@ public class RedisConfig { return redisTemplate; } - /** - * 统一的 JsonMapper Bean - *

- * 禁止将日期序列化为时间戳;不写入类型信息,保证 Redis 存储的 JSON 纯净可读。 - * 需要反序列化到特定类型时,调用方应使用 {@link JsonMapper#convertValue(Object, Class)} 显式转换。 - */ - @Bean - public JsonMapper jsonMapper() { - return JsonMapper.builder() - .disable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS) - .build(); - } - } diff --git a/src/main/java/com/youlai/boot/framework/mybatis/exception/DataPermissionException.java b/src/main/java/com/youlai/boot/framework/mybatis/exception/DataPermissionException.java deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/com/youlai/boot/framework/mybatis/handler/AutoFillMetaObjectHandler.java b/src/main/java/com/youlai/boot/framework/mybatis/handler/AutoFillMetaObjectHandler.java index f5e9d893..80d260e6 100644 --- a/src/main/java/com/youlai/boot/framework/mybatis/handler/AutoFillMetaObjectHandler.java +++ b/src/main/java/com/youlai/boot/framework/mybatis/handler/AutoFillMetaObjectHandler.java @@ -1,6 +1,7 @@ package com.youlai.boot.framework.mybatis.handler; import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; +import com.youlai.boot.framework.security.util.SecurityUtils; import org.apache.ibatis.reflection.MetaObject; import org.springframework.stereotype.Component; @@ -9,6 +10,9 @@ import java.time.LocalDateTime; /** * MyBatis-Plus 字段自动填充 * + *

自动填充 createTime/updateTime(时间戳)和 createBy/updateBy(操作人ID)

+ *

实体字段需标注 {@code @TableField(fill = FieldFill.INSERT)} 或 {@code FieldFill.INSERT_UPDATE} 才会触发

+ * * @author Ray.Hao * @since 3.0.0 */ @@ -18,11 +22,14 @@ public class AutoFillMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime::now, LocalDateTime.class); + this.strictInsertFill(metaObject, "createBy", SecurityUtils::getUserId, Long.class); this.strictUpdateFill(metaObject, "updateTime", LocalDateTime::now, LocalDateTime.class); + this.strictUpdateFill(metaObject, "updateBy", SecurityUtils::getUserId, Long.class); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime::now, LocalDateTime.class); + this.strictUpdateFill(metaObject, "updateBy", SecurityUtils::getUserId, Long.class); } } diff --git a/src/main/java/com/youlai/boot/framework/mybatis/interceptor/MyDataPermissionHandler.java b/src/main/java/com/youlai/boot/framework/mybatis/interceptor/MyDataPermissionHandler.java index 7dd4e003..74d6ad33 100644 --- a/src/main/java/com/youlai/boot/framework/mybatis/interceptor/MyDataPermissionHandler.java +++ b/src/main/java/com/youlai/boot/framework/mybatis/interceptor/MyDataPermissionHandler.java @@ -4,10 +4,10 @@ import cn.hutool.core.collection.CollectionUtil; import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.toolkit.StringPool; import com.baomidou.mybatisplus.extension.plugins.handler.DataPermissionHandler; -import com.youlai.boot.common.annotation.DataPermission; +import com.youlai.boot.framework.annotation.DataPermission; import com.youlai.boot.common.enums.DataScopeEnum; import com.youlai.boot.framework.security.model.RoleDataScope; -import com.youlai.boot.framework.security.model.SysUserDetails; +import com.youlai.boot.framework.security.model.SecurityUserDetails; import com.youlai.boot.framework.security.util.SecurityUtils; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; @@ -58,7 +58,7 @@ public class MyDataPermissionHandler implements DataPermissionHandler { // 获取当前用户的数据权限列表 List dataScopes = SecurityUtils.getUser() - .map(SysUserDetails::getDataScopes) + .map(SecurityUserDetails::getDataScopes) .orElse(List.of()); // 如果任一角色是 ALL,则跳过数据权限过滤(并集策略) diff --git a/src/main/java/com/youlai/boot/framework/apidoc/Knife4jOpenApiCustomizer.java b/src/main/java/com/youlai/boot/framework/openapi/Knife4jOpenApiCustomizer.java similarity index 100% rename from src/main/java/com/youlai/boot/framework/apidoc/Knife4jOpenApiCustomizer.java rename to src/main/java/com/youlai/boot/framework/openapi/Knife4jOpenApiCustomizer.java diff --git a/src/main/java/com/youlai/boot/framework/apidoc/OpenApiConfig.java b/src/main/java/com/youlai/boot/framework/openapi/OpenApiConfig.java similarity index 100% rename from src/main/java/com/youlai/boot/framework/apidoc/OpenApiConfig.java rename to src/main/java/com/youlai/boot/framework/openapi/OpenApiConfig.java diff --git a/src/main/java/com/youlai/boot/framework/security/filter/TokenAuthenticationFilter.java b/src/main/java/com/youlai/boot/framework/security/filter/TokenAuthenticationFilter.java index 4aad3db0..c4298f35 100644 --- a/src/main/java/com/youlai/boot/framework/security/filter/TokenAuthenticationFilter.java +++ b/src/main/java/com/youlai/boot/framework/security/filter/TokenAuthenticationFilter.java @@ -2,78 +2,72 @@ package com.youlai.boot.framework.security.filter; import cn.hutool.core.util.StrUtil; import com.youlai.boot.common.constant.SecurityConstants; -import com.youlai.boot.common.result.ResultCode; -import com.youlai.boot.framework.web.util.ResponseWriter; import com.youlai.boot.framework.security.token.TokenManager; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.InsufficientAuthenticationException; import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; /** - * Token 认证校验过滤器 + * Token 认证过滤器。 + *

+ * 仅负责解析 Token 和填充 {@link SecurityContextHolder}。 + * 无效 Token 抛出 {@link AuthenticationException},由使用方的 + * {@code AuthenticationEntryPoint} 统一处理响应格式。 + *

+ * 必须注册在 {@code ExceptionTranslationFilter} 之后(即 {@code AuthorizationFilter} 之前), + * 这样抛出的异常才能被 {@code ExceptionTranslationFilter} 捕获。 * * @author wangtao - * @since 2025/3/6 16:50 + * @since 2025/3/6 */ public class TokenAuthenticationFilter extends OncePerRequestFilter { - /** - * Token 管理器 - */ private final TokenManager tokenManager; public TokenAuthenticationFilter(TokenManager tokenManager) { this.tokenManager = tokenManager; } - /** - * 校验 Token ,包括验签和是否过期 - * 如果 Token 有效,将 Token 解析为 Authentication 对象,并设置到 Spring Security 上下文中 - */ @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { String rawToken = resolveToken(request); - try { - if (StrUtil.isNotBlank(rawToken)) { - // 执行令牌有效性检查(包含密码学验签和过期时间验证) - boolean isValidToken = tokenManager.validateToken(rawToken); - if (!isValidToken) { - ResponseWriter.writeError(response, ResultCode.ACCESS_TOKEN_INVALID); - return; + if (StrUtil.isNotBlank(rawToken)) { + try { + boolean isValid = tokenManager.validateToken(rawToken); + if (!isValid) { + SecurityContextHolder.clearContext(); + throw new InsufficientAuthenticationException("Token 无效或已过期"); } - - // 将令牌解析为 Spring Security 上下文认证对象 Authentication authentication = tokenManager.parseToken(rawToken); SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (AuthenticationException ex) { + SecurityContextHolder.clearContext(); + throw ex; + } catch (Exception ex) { + SecurityContextHolder.clearContext(); + throw new InsufficientAuthenticationException("Token 认证失败", ex); } - } catch (Exception ex) { - // 安全上下文清除保障(防止上下文残留) - SecurityContextHolder.clearContext(); - ResponseWriter.writeError(response, ResultCode.ACCESS_TOKEN_INVALID); - return; } - // 继续后续过滤器链执行 filterChain.doFilter(request, response); } - /** - * 从请求中解析 Token(仅支持 Authorization Header) - */ private String resolveToken(HttpServletRequest request) { - String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); - if (StrUtil.isNotBlank(authorizationHeader) - && authorizationHeader.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) { - return authorizationHeader.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length()); + String header = request.getHeader(HttpHeaders.AUTHORIZATION); + if (StrUtil.isNotBlank(header) && header.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) { + return header.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length()); } return null; } diff --git a/src/main/java/com/youlai/boot/framework/security/model/UserAuthInfo.java b/src/main/java/com/youlai/boot/framework/security/model/SecurityUser.java similarity index 73% rename from src/main/java/com/youlai/boot/framework/security/model/UserAuthInfo.java rename to src/main/java/com/youlai/boot/framework/security/model/SecurityUser.java index e6b17d21..96ee67ef 100644 --- a/src/main/java/com/youlai/boot/framework/security/model/UserAuthInfo.java +++ b/src/main/java/com/youlai/boot/framework/security/model/SecurityUser.java @@ -6,16 +6,16 @@ import java.util.List; import java.util.Set; /** - * 用户认证信息 + * 安全模块用户数据 POJO。 *

- * 用于登录认证过程中的用户信息承载,包含用户名、密码、状态、角色等与认证/授权相关的数据。 - *

+ * 作为端口接口的返回类型,承载用户认证所需的全部数据。 + * 纯 JDK 类型,无 system 模块依赖,可直接序列化。 * * @author Ray.Hao * @since 2025/12/16 */ @Data -public class UserAuthInfo { +public class SecurityUser { /** * 用户ID @@ -48,7 +48,12 @@ public class UserAuthInfo { private Integer status; /** - * 角色集合 + * 头像 URL + */ + private String avatar; + + /** + * 角色编码集合 */ private Set roles; diff --git a/src/main/java/com/youlai/boot/framework/security/model/SysUserDetails.java b/src/main/java/com/youlai/boot/framework/security/model/SecurityUserDetails.java similarity index 65% rename from src/main/java/com/youlai/boot/framework/security/model/SysUserDetails.java rename to src/main/java/com/youlai/boot/framework/security/model/SecurityUserDetails.java index 2e733b11..eeecd5f3 100644 --- a/src/main/java/com/youlai/boot/framework/security/model/SysUserDetails.java +++ b/src/main/java/com/youlai/boot/framework/security/model/SecurityUserDetails.java @@ -13,17 +13,18 @@ import java.util.*; import java.util.stream.Collectors; /** - * Spring Security 用户认证对象 + * Spring Security 用户认证对象。 *

- * 封装了用户的基本信息和权限信息,供 Spring Security 进行用户认证与授权。 - * 实现了 {@link UserDetails} 接口,提供用户的核心信息。 + * 实现 {@link UserDetails},封装用户标识、角色、数据权限等认证信息。 + * {@link #roles} 字段存储角色编码(不带 ROLE_ 前缀), + * {@link #getAuthorities()} 运行时补前缀并转为 {@link SimpleGrantedAuthority}。 * * @author Ray.Hao * @version 3.0.0 */ @Data @NoArgsConstructor -public class SysUserDetails implements UserDetails { +public class SecurityUserDetails implements UserDetails { /** * 用户ID @@ -52,42 +53,35 @@ public class SysUserDetails implements UserDetails { /** * 数据权限列表 - *

- * 存储用户所有角色的数据权限范围,用于实现多角色权限合并(并集策略) */ private List dataScopes; /** - * 用户角色权限集合 + * 角色编码集合(不带 ROLE_ 前缀) */ - private Collection authorities; + private Set roles; /** - * 构造函数:根据用户认证信息初始化用户详情对象 - * - * @param user 用户认证信息对象 {@link UserAuthInfo} + * 构造函数:根据 {@link SecurityUser} 初始化。 */ - public SysUserDetails(UserAuthInfo user) { + public SecurityUserDetails(SecurityUser user) { this.userId = user.getUserId(); this.username = user.getUsername(); this.password = user.getPassword(); this.enabled = ObjectUtil.equal(user.getStatus(), 1); this.deptId = user.getDeptId(); this.dataScopes = user.getDataScopes(); - - // 初始化角色权限集合 - this.authorities = CollectionUtil.isNotEmpty(user.getRoles()) - ? user.getRoles().stream() - // 角色名加上前缀 "ROLE_",用于区分角色 (ROLE_ADMIN) 和权限 (user:add) - .map(role -> new SimpleGrantedAuthority(SecurityConstants.ROLE_PREFIX + role)) - .collect(Collectors.toSet()) - : Collections.emptySet(); + this.roles = user.getRoles(); } - @Override public Collection getAuthorities() { - return this.authorities; + if (CollectionUtil.isEmpty(roles)) { + return Collections.emptySet(); + } + return roles.stream() + .map(role -> new SimpleGrantedAuthority(SecurityConstants.ROLE_PREFIX + role)) + .collect(Collectors.toSet()); } @Override @@ -107,8 +101,6 @@ public class SysUserDetails implements UserDetails { /** * 判断是否包含"全部数据"权限 - * - * @return 是否有全部数据权限 */ public boolean hasAllDataScope() { if (CollectionUtil.isEmpty(dataScopes)) { @@ -119,9 +111,7 @@ public class SysUserDetails implements UserDetails { } /** - * 获取数据权限列表 - * - * @return 数据权限列表,永不为null + * 获取数据权限列表,永不为 null */ public List getDataScopes() { return dataScopes != null ? dataScopes : Collections.emptyList(); diff --git a/src/main/java/com/youlai/boot/framework/security/model/UserSession.java b/src/main/java/com/youlai/boot/framework/security/model/UserSession.java deleted file mode 100644 index a798a4c5..00000000 --- a/src/main/java/com/youlai/boot/framework/security/model/UserSession.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.youlai.boot.framework.security.model; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.List; -import java.util.Set; - -/** - * 用户会话信息 - *

- * 存储在Token中的用户会话快照,包含用户身份、数据权限和角色权限信息。 - * 用于Redis-Token模式下的会话管理,支持在线用户查询和会话控制。 - * - * @author wangtao - * @since 2025/2/27 10:31 - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -public class UserSession { - - /** - * 用户ID - */ - private Long userId; - - /** - * 用户名 - */ - private String username; - - /** - * 部门ID - */ - private Long deptId; - - /** - * 数据权限列表 - */ - private List dataScopes; - - /** - * 角色权限集合 - */ - private Set roles; - -} diff --git a/src/main/java/com/youlai/boot/framework/security/model/WxMaBindInfo.java b/src/main/java/com/youlai/boot/framework/security/model/WxMaBindInfo.java deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/com/youlai/boot/framework/security/port/PermissionPort.java b/src/main/java/com/youlai/boot/framework/security/port/PermissionPort.java new file mode 100644 index 00000000..9417eded --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/port/PermissionPort.java @@ -0,0 +1,22 @@ +package com.youlai.boot.framework.security.port; + +import java.util.Set; + +/** + * 权限查询端口。 + *

+ * 由 system 模块提供适配器实现,framework 层通过此接口获取角色权限集合, + * 不直接依赖 system 模块的 {@code RoleMenuService}。 + * + * @see com.youlai.boot.system.security.adapter.PermissionAdapter + */ +public interface PermissionPort { + + /** + * 根据角色编码集合查询权限标识集合。 + * + * @param roleCodes 角色编码集合 + * @return 权限标识集合,如 "sys:user:create" + */ + Set getRolePerms(Set roleCodes); +} diff --git a/src/main/java/com/youlai/boot/framework/security/port/UserAuthenticationPort.java b/src/main/java/com/youlai/boot/framework/security/port/UserAuthenticationPort.java new file mode 100644 index 00000000..82acea2c --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/port/UserAuthenticationPort.java @@ -0,0 +1,40 @@ +package com.youlai.boot.framework.security.port; + +import com.youlai.boot.common.enums.SocialPlatformEnum; +import com.youlai.boot.framework.security.model.SecurityUser; + +/** + * 用户认证信息查询端口。 + *

+ * 由 system 模块提供适配器实现,framework 层通过此接口获取认证数据, + * 不直接依赖 system 模块的 {@code UserService} / {@code UserSocialService}。 + * + * @see com.youlai.boot.system.security.adapter.UserAuthenticationAdapter + */ +public interface UserAuthenticationPort { + + /** + * 根据用户名查询认证信息。 + * + * @param username 用户名 + * @return 认证信息,不存在返回 null + */ + SecurityUser getAuthInfoByUsername(String username); + + /** + * 根据手机号查询认证信息。 + * + * @param mobile 手机号 + * @return 认证信息,不存在返回 null + */ + SecurityUser getAuthInfoByMobile(String mobile); + + /** + * 根据第三方平台 openid 查询认证信息。 + * + * @param platform 第三方平台 + * @param openid openid + * @return 认证信息,未绑定返回 null + */ + SecurityUser getAuthInfoByOpenid(SocialPlatformEnum platform, String openid); +} diff --git a/src/main/java/com/youlai/boot/framework/security/service/PermissionService.java b/src/main/java/com/youlai/boot/framework/security/service/PermissionService.java index 46516f3c..ef7cc446 100644 --- a/src/main/java/com/youlai/boot/framework/security/service/PermissionService.java +++ b/src/main/java/com/youlai/boot/framework/security/service/PermissionService.java @@ -2,8 +2,8 @@ package com.youlai.boot.framework.security.service; import cn.hutool.core.collection.CollectionUtil; import cn.hutool.core.util.StrUtil; +import com.youlai.boot.framework.security.port.PermissionPort; import com.youlai.boot.framework.security.util.SecurityUtils; -import com.youlai.boot.system.service.RoleMenuService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -12,11 +12,10 @@ import org.springframework.util.PatternMatchUtils; import java.util.Set; /** - * Spring Security 权限校验组件 + * Spring Security 权限校验组件。 *

- * 用于 SpEL 表达式权限校验,如:@PreAuthorize("@ss.hasPerm('sys:user:create')") - *

- * 权限数据来源:{@link RoleMenuService#getRolePermsByRoleCodes}(带 Redis 缓存) + * 用于 SpEL 表达式:{@code @PreAuthorize("@ss.hasPerm('sys:user:create')")}。 + * 通过 {@link PermissionPort} 查询角色权限,不直接依赖 system 模块。 * * @author Ray.Hao * @since 0.0.1 @@ -26,39 +25,30 @@ import java.util.Set; @Slf4j public class PermissionService { - private final RoleMenuService roleMenuService; + private final PermissionPort permissionPort; /** - * 判断当前登录用户是否拥有操作权限 - *

- * 支持通配符匹配,如:权限码 "sys:user:*" 可匹配 "sys:user:create"、"sys:user:delete" 等 - * - * @param requiredPerm 所需权限 - * @return 是否有权限 + * 判断当前用户是否拥有操作权限,支持通配符匹配。 */ public boolean hasPerm(String requiredPerm) { if (StrUtil.isBlank(requiredPerm)) { return false; } - // 超级管理员放行 if (SecurityUtils.isRoot()) { return true; } - // 获取当前登录用户的角色编码集合 Set roleCodes = SecurityUtils.getRoles(); if (CollectionUtil.isEmpty(roleCodes)) { return false; } - // 获取当前登录用户的所有角色的权限列表(从缓存读取) - Set rolePerms = roleMenuService.getRolePermsByRoleCodes(roleCodes); + Set rolePerms = permissionPort.getRolePerms(roleCodes); if (CollectionUtil.isEmpty(rolePerms)) { return false; } - // 判断权限列表中是否包含所需权限(支持通配符) boolean hasPermission = rolePerms.stream() .anyMatch(rolePerm -> PatternMatchUtils.simpleMatch(rolePerm, requiredPerm)); diff --git a/src/main/java/com/youlai/boot/framework/security/service/RolePermissionService.java b/src/main/java/com/youlai/boot/framework/security/service/RolePermissionService.java deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/com/youlai/boot/framework/security/service/SecurityUserDetailsService.java b/src/main/java/com/youlai/boot/framework/security/service/SecurityUserDetailsService.java new file mode 100644 index 00000000..9b998c22 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/service/SecurityUserDetailsService.java @@ -0,0 +1,41 @@ +package com.youlai.boot.framework.security.service; + +import com.youlai.boot.framework.security.model.SecurityUser; +import com.youlai.boot.framework.security.model.SecurityUserDetails; +import com.youlai.boot.framework.security.port.UserAuthenticationPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +/** + * 系统用户认证 DetailsService。 + *

+ * 通过 {@link UserAuthenticationPort} 获取认证信息,不直接依赖 system 模块。 + * + * @author Ray.Hao + * @since 2021/10/19 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class SecurityUserDetailsService implements UserDetailsService { + + private final UserAuthenticationPort userAuthPort; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + try { + SecurityUser securityUser = userAuthPort.getAuthInfoByUsername(username); + if (securityUser == null) { + throw new UsernameNotFoundException(username); + } + return new SecurityUserDetails(securityUser); + } catch (Exception e) { + log.error("认证异常:{}", e.getMessage()); + throw e; + } + } +} diff --git a/src/main/java/com/youlai/boot/framework/security/service/SysUserDetailsService.java b/src/main/java/com/youlai/boot/framework/security/service/SysUserDetailsService.java deleted file mode 100644 index 2bbe035b..00000000 --- a/src/main/java/com/youlai/boot/framework/security/service/SysUserDetailsService.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.youlai.boot.framework.security.service; - -import com.youlai.boot.framework.security.model.SysUserDetails; -import com.youlai.boot.framework.security.model.UserAuthInfo; -import com.youlai.boot.system.enums.SocialPlatformEnum; -import com.youlai.boot.system.model.entity.UserSocial; -import com.youlai.boot.system.service.UserSocialService; -import com.youlai.boot.system.service.UserService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.stereotype.Service; - -/** - * 系统用户认证 DetailsService - * - * @author Ray.Hao - * @since 2021/10/19 - */ -@Service -@RequiredArgsConstructor -@Slf4j -public class SysUserDetailsService implements UserDetailsService { - - private final UserService userService; - private final UserSocialService userSocialService; - - /** - * 根据用户名获取用户信息 - * - * @param username 用户名 - * @return 用户信息 - * @throws UsernameNotFoundException 用户名未找到异常 - */ - @Override - public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - try { - UserAuthInfo userAuthInfo = userService.getAuthInfoByUsername(username); - if (userAuthInfo == null) { - throw new UsernameNotFoundException(username); - } - return new SysUserDetails(userAuthInfo); - } catch (Exception e) { - // 记录异常日志 - log.error("认证异常:{}", e.getMessage()); - // 抛出异常 - throw e; - } - } - - /** - * 根据微信小程序openid查询绑定信息 - * - * @param openid 微信小程序openid - * @return 绑定信息,未绑定返回null - */ - public UserSocial getWechatMiniBindInfo(String openid) { - return userSocialService.getByPlatformAndOpenid(SocialPlatformEnum.WECHAT_MINI, openid); - } - - /** - * 根据微信小程序openid获取用户认证信息 - * - * @param openid 微信小程序openid - * @return 用户认证信息,用户不存在返回null - */ - public UserAuthInfo getAuthInfoByWechatOpenid(String openid) { - return userSocialService.getAuthInfoByOpenid(SocialPlatformEnum.WECHAT_MINI, openid); - } - - /** - * 更新微信小程序session_key - * - * @param bindId 绑定记录ID - * @param sessionKey session_key - */ - public void updateWechatSessionKey(Long bindId, String sessionKey) { - userSocialService.updateSessionKey(bindId, sessionKey); - } -} diff --git a/src/main/java/com/youlai/boot/framework/security/service/UserAuthQueryService.java b/src/main/java/com/youlai/boot/framework/security/service/UserAuthQueryService.java deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/com/youlai/boot/framework/security/service/WxMaUserAuthQueryService.java b/src/main/java/com/youlai/boot/framework/security/service/WxMaUserAuthQueryService.java deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/com/youlai/boot/framework/security/token/JwtTokenManager.java b/src/main/java/com/youlai/boot/framework/security/token/JwtTokenManager.java index 1b153f64..bdd44dab 100644 --- a/src/main/java/com/youlai/boot/framework/security/token/JwtTokenManager.java +++ b/src/main/java/com/youlai/boot/framework/security/token/JwtTokenManager.java @@ -17,30 +17,25 @@ import com.youlai.boot.framework.security.config.SecurityProperties; import com.youlai.boot.framework.security.exception.TokenInvalidException; import com.youlai.boot.framework.security.model.AuthenticationToken; import com.youlai.boot.framework.security.model.RoleDataScope; +import com.youlai.boot.framework.security.model.SecurityUserDetails; import org.apache.commons.lang3.StringUtils; -import com.youlai.boot.framework.security.model.SysUserDetails; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.stereotype.Service; import java.util.*; -import java.util.concurrent.TimeUnit; // Import TimeUnit +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** - * JWT Token 管理器 + * JWT Token 管理器。 *

- * 实现基于JWT的无状态认证,支持: - *

    - *
  • Access Token + Refresh Token 双令牌机制
  • - *
  • Token 撤销(jti黑名单)
  • - *
  • 用户级会话失效(tokenVersion)
  • - *
  • 多角色数据权限存储
  • - *
+ * 基于 JWT 的无状态认证,支持 Access + Refresh 双令牌、Token 撤销(jti 黑名单)、 + * 用户级会话失效(tokenVersion)、多角色数据权限存储。 + *

+ * JWT claims 中存储角色编码(不带 ROLE_ 前缀),解析后由 {@link SecurityUserDetails#getAuthorities()} 运行时补前缀。 * * @author Ray.Hao * @since 2024/11/15 @@ -59,12 +54,6 @@ public class JwtTokenManager implements TokenManager { this.secretKey = securityProperties.getSession().getJwt().getSecretKey().getBytes(); } - /** - * 生成令牌 - * - * @param authentication 认证信息 - * @return 令牌响应对象 - */ @Override public AuthenticationToken generateToken(Authentication authentication) { int accessTokenTimeToLive = securityProperties.getSession().getAccessTokenTimeToLive(); @@ -81,22 +70,15 @@ public class JwtTokenManager implements TokenManager { .build(); } - /** - * 解析令牌 - * - * @param token JWT Token - * @return Authentication 对象 - */ @Override public Authentication parseToken(String token) { - JWT jwt = JWTUtil.parseToken(token); JSONObject payloads = jwt.getPayloads(); - SysUserDetails userDetails = new SysUserDetails(); - userDetails.setUserId(payloads.getLong(JwtClaimConstants.USER_ID)); // 用户ID - userDetails.setDeptId(payloads.getLong(JwtClaimConstants.DEPT_ID)); // 部门ID + SecurityUserDetails userDetails = new SecurityUserDetails(); + userDetails.setUserId(payloads.getLong(JwtClaimConstants.USER_ID)); + userDetails.setDeptId(payloads.getLong(JwtClaimConstants.DEPT_ID)); - // 解析数据权限列表 + // 数据权限 JSONArray dataScopesArray = payloads.getJSONArray(JwtClaimConstants.DATA_SCOPES); if (dataScopesArray != null && !dataScopesArray.isEmpty()) { List dataScopes = dataScopesArray.stream() @@ -115,88 +97,60 @@ public class JwtTokenManager implements TokenManager { userDetails.setDataScopes(dataScopes); } - userDetails.setUsername(payloads.getStr(JWTPayload.SUBJECT)); // 用户名 - // 角色集合 - Set authorities = payloads.getJSONArray(JwtClaimConstants.AUTHORITIES) - .stream() - .map(authority -> new SimpleGrantedAuthority(Convert.toStr(authority))) - .collect(Collectors.toSet()); + userDetails.setUsername(payloads.getStr(JWTPayload.SUBJECT)); - return new UsernamePasswordAuthenticationToken(userDetails, "", authorities); + // 角色编码(不带 ROLE_ 前缀) + JSONArray rolesArray = payloads.getJSONArray(JwtClaimConstants.ROLES); + if (rolesArray != null && !rolesArray.isEmpty()) { + Set roles = rolesArray.stream() + .map(Convert::toStr) + .collect(Collectors.toSet()); + userDetails.setRoles(roles); + } + + return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); } - /** - * 校验令牌 - * - * @param token JWT Token - * @return 是否有效 - */ @Override public boolean validateToken(String token) { return validateToken(token, false); } - /** - * 校验刷新令牌 - * - * @param refreshToken JWT Token - * @return 验证结果 - */ @Override public boolean validateRefreshToken(String refreshToken) { return validateToken(refreshToken, true); } - /** - * 校验令牌 - *

- * 校验流程(按顺序执行): - *

    - *
  1. 签名验证 + 过期时间检查
  2. - *
  3. 刷新令牌类型校验(仅刷新场景)
  4. - *
  5. tokenVersion 校验(用户级会话失效)
  6. - *
  7. jti 黑名单校验(单Token撤销)
  8. - *
- * - * @param token JWT Token - * @param validateRefreshToken 是否校验刷新令牌类型 - * @return 是否有效 - */ private boolean validateToken(String token, boolean validateRefreshToken) { JWT jwt = JWTUtil.parseToken(token); - // 检查 Token 是否有效(验签 + 是否过期) boolean isValid = jwt.setKey(secretKey).validate(0); if (isValid) { JSONObject payloads = jwt.getPayloads(); - // 1. 校验刷新令牌类型(仅在校验刷新令牌场景启用) + // 刷新令牌类型校验 String jti = payloads.getStr(JWTPayload.JWT_ID); if (validateRefreshToken) { - //刷新token需要校验token类别 boolean isRefreshToken = payloads.getBool(JwtClaimConstants.TOKEN_TYPE); if (!isRefreshToken) { return false; } } - // 2. 校验 tokenVersion(用于按用户维度失效历史 Token) - // 场景示例:用户修改密码、被管理员强制下线、手动"踢所有端"后,递增 tokenVersion, - // 之前签发的 Token 因版本号不匹配而失效 + + // tokenVersion 校验(用户维度 Token 失效) Long userId = payloads.getLong(JwtClaimConstants.USER_ID); if (userId != null) { Integer tokenVersion = payloads.getInt(JwtClaimConstants.TOKEN_VERSION); - + String versionKey = StrUtil.format(RedisConstants.Auth.USER_TOKEN_VERSION, userId); Object currentVersionObj = redisTemplate.opsForValue().get(versionKey); int currentVersion = currentVersionObj != null ? Convert.toInt(currentVersionObj) : 0; - // 版本号不匹配则 Token 无效(新签发的 Token 版本号必须 >= Redis 中的版本号) if (tokenVersion == null || tokenVersion < currentVersion) { return false; } } - // 3. 判断 Token 是否已被撤销(单端退出/会话注销) - // 场景示例:单点退出登录、后台手动注销某个会话、封禁账号后立即阻断当前 Token 等 + // jti 黑名单校验 if (isTokenRevoked(jti)) { return false; } @@ -204,17 +158,11 @@ public class JwtTokenManager implements TokenManager { return isValid; } - /** - * 将令牌加入黑名单 - * - * @param token JWT Token - */ @Override public void invalidateToken(String token) { if (StringUtils.isBlank(token)) { return; } - if (token.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) { token = token.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length()); } @@ -225,77 +173,15 @@ public class JwtTokenManager implements TokenManager { revokeTokenByJti(jti, expirationAt); } - /** - * 检查Token是否已被撤销 - * - * @param jti Token唯一标识 - * @return true-已撤销,false-未撤销 - */ - private boolean isTokenRevoked(String jti) { - if (StringUtils.isBlank(jti)) { - return false; - } - return Boolean.TRUE.equals(redisTemplate.hasKey(StrUtil.format(RedisConstants.Auth.REVOKED_JTI, jti))); - } - - /** - * 将Token加入撤销黑名单 - *

- * 黑名单有效期与Token剩余有效期一致,避免永久存储 - * - * @param jti Token唯一标识 - * @param expirationAt Token过期时间戳 - */ - private void revokeTokenByJti(String jti, Integer expirationAt) { - if (StringUtils.isBlank(jti)) { - return; - } - - String revokedJtiKey = StrUtil.format(RedisConstants.Auth.REVOKED_JTI, jti); - if (expirationAt != null) { - int currentTimeSeconds = Convert.toInt(System.currentTimeMillis() / 1000); - if (expirationAt < currentTimeSeconds) { - return; - } - int expirationIn = expirationAt - currentTimeSeconds; - redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE, expirationIn, TimeUnit.SECONDS); - } else { - redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE); - } - } - - /** - * 失效指定用户的所有会话 - *

- * 通过递增用户 tokenVersion,使该用户之前签发的所有 Token 因版本号不匹配而失效。 - *

- * 适用场景: - *

    - *
  • 用户修改密码
  • - *
  • 管理员强制下线用户
  • - *
  • 用户主动踢出所有设备
  • - *
  • 用户被禁用
  • - *
- * - * @param userId 用户ID - */ @Override public void invalidateUserSessions(Long userId) { if (userId == null) { return; } - String versionKey = StrUtil.format(RedisConstants.Auth.USER_TOKEN_VERSION, userId); - // 递增版本号,无需设置 TTL(版本号永久有效,避免 TTL 过期导致的安全问题) redisTemplate.opsForValue().increment(versionKey); } - /** - * 刷新令牌 - * - * @param refreshToken 刷新令牌 - * @return 令牌响应对象 - */ @Override public AuthenticationToken refreshToken(String refreshToken) { boolean isValid = validateRefreshToken(refreshToken); @@ -313,45 +199,19 @@ public class JwtTokenManager implements TokenManager { .build(); } - /** - * 生成 JWT Token - * - * @param authentication 认证信息 - * @param ttl 过期时间(秒),-1表示永不过期 - * @return JWT Token字符串 - */ + // ======================== private ======================== + private String generateToken(Authentication authentication, int ttl) { return generateToken(authentication, ttl, false); } - - /** - * 生成 JWT Token - *

- * Payload包含: - *

    - *
  • userId - 用户ID
  • - *
  • deptId - 部门ID
  • - *
  • dataScopes - 数据权限列表
  • - *
  • authorities - 角色权限集合
  • - *
  • tokenType - 是否为刷新令牌
  • - *
  • tokenVersion - Token版本号(用于会话失效控制)
  • - *
  • iat/exp - 签发/过期时间
  • - *
  • jti - Token唯一标识(用于撤销)
  • - *
- * - * @param authentication 认证信息 - * @param ttl 过期时间(秒) - * @param isRefreshToken 是否为刷新令牌 - * @return JWT Token字符串 - */ private String generateToken(Authentication authentication, int ttl, boolean isRefreshToken) { - SysUserDetails userDetails = (SysUserDetails) authentication.getPrincipal(); + SecurityUserDetails userDetails = (SecurityUserDetails) authentication.getPrincipal(); Map payload = new HashMap<>(); - payload.put(JwtClaimConstants.USER_ID, userDetails.getUserId()); // 用户ID - payload.put(JwtClaimConstants.DEPT_ID, userDetails.getDeptId()); // 部门ID + payload.put(JwtClaimConstants.USER_ID, userDetails.getUserId()); + payload.put(JwtClaimConstants.DEPT_ID, userDetails.getDeptId()); - // 存储数据权限列表 + // 数据权限 List dataScopes = userDetails.getDataScopes(); if (dataScopes != null && !dataScopes.isEmpty()) { List> scopesList = dataScopes.stream() @@ -366,13 +226,12 @@ public class JwtTokenManager implements TokenManager { payload.put(JwtClaimConstants.DATA_SCOPES, scopesList); } - // claims 中添加角色信息 - Set roles = authentication.getAuthorities().stream() - .map(GrantedAuthority::getAuthority) - .collect(Collectors.toSet()); - payload.put(JwtClaimConstants.AUTHORITIES, roles); + // 角色编码(不带 ROLE_ 前缀) + Set roles = userDetails.getRoles(); + if (roles != null && !roles.isEmpty()) { + payload.put(JwtClaimConstants.ROLES, roles); + } - // 获取当前用户的 Token 版本号,用于会话失效控制 Long userId = userDetails.getUserId(); int tokenVersion = 0; if (userId != null) { @@ -384,12 +243,8 @@ public class JwtTokenManager implements TokenManager { Date now = new Date(); payload.put(JWTPayload.ISSUED_AT, now); - payload.put(JwtClaimConstants.TOKEN_TYPE, false); - if (isRefreshToken) { - payload.put(JwtClaimConstants.TOKEN_TYPE, true); - } + payload.put(JwtClaimConstants.TOKEN_TYPE, isRefreshToken); - // 设置过期时间 -1 表示永不过期 if (ttl != -1) { Date expiresAt = DateUtil.offsetSecond(now, ttl); payload.put(JWTPayload.EXPIRES_AT, expiresAt); @@ -400,4 +255,27 @@ public class JwtTokenManager implements TokenManager { return JWTUtil.createToken(payload, secretKey); } + private boolean isTokenRevoked(String jti) { + if (StringUtils.isBlank(jti)) { + return false; + } + return Boolean.TRUE.equals(redisTemplate.hasKey(StrUtil.format(RedisConstants.Auth.REVOKED_JTI, jti))); + } + + private void revokeTokenByJti(String jti, Integer expirationAt) { + if (StringUtils.isBlank(jti)) { + return; + } + String revokedJtiKey = StrUtil.format(RedisConstants.Auth.REVOKED_JTI, jti); + if (expirationAt != null) { + int currentTimeSeconds = Convert.toInt(System.currentTimeMillis() / 1000); + if (expirationAt < currentTimeSeconds) { + return; + } + int expirationIn = expirationAt - currentTimeSeconds; + redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE, expirationIn, TimeUnit.SECONDS); + } else { + redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE); + } + } } diff --git a/src/main/java/com/youlai/boot/framework/security/token/RedisTokenManager.java b/src/main/java/com/youlai/boot/framework/security/token/RedisTokenManager.java index 52481ad4..661a8b46 100644 --- a/src/main/java/com/youlai/boot/framework/security/token/RedisTokenManager.java +++ b/src/main/java/com/youlai/boot/framework/security/token/RedisTokenManager.java @@ -1,6 +1,5 @@ package com.youlai.boot.framework.security.token; -import cn.hutool.core.collection.CollectionUtil; import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.StrUtil; import com.youlai.boot.common.constant.RedisConstants; @@ -9,35 +8,25 @@ import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.framework.security.config.SecurityProperties; import com.youlai.boot.framework.security.exception.TokenInvalidException; import com.youlai.boot.framework.security.model.AuthenticationToken; -import com.youlai.boot.framework.security.model.UserSession; -import com.youlai.boot.framework.security.model.SysUserDetails; +import com.youlai.boot.framework.security.model.SecurityUserDetails; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.stereotype.Service; import tools.jackson.databind.json.JsonMapper; import java.util.Optional; -import java.util.Set; import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; /** - * Redis Token 管理器 + * Redis Token 管理器。 *

- * 实现基于Redis的有状态认证,支持: - *

    - *
  • Access Token + Refresh Token 双令牌机制
  • - *
  • 单设备/多设备登录控制
  • - *
  • 用户级会话失效
  • - *
  • 在线用户管理
  • - *
+ * 基于 Redis 的有状态 Token 认证,支持 Access + Refresh 双令牌、 + * 单/多设备登录控制、用户级会话失效。 *

- * 与JWT模式相比,Redis模式支持主动踢人、在线用户查询等功能 + * 直接存取 {@link SecurityUserDetails}(password 置 null),无需 UserSession 中间层。 * * @author Ray.Hao * @since 2024/11/15 @@ -58,33 +47,16 @@ public class RedisTokenManager implements TokenManager { this.jsonMapper = jsonMapper; } - /** - * 生成 Token - * - * @param authentication 用户认证信息 - * @return 生成的 AuthenticationToken 对象 - */ @Override public AuthenticationToken generateToken(Authentication authentication) { - SysUserDetails user = (SysUserDetails) authentication.getPrincipal(); + SecurityUserDetails user = (SecurityUserDetails) authentication.getPrincipal(); String accessToken = IdUtil.fastSimpleUUID(); String refreshToken = IdUtil.fastSimpleUUID(); - // 构建用户会话信息 - UserSession userSession = new UserSession( - user.getUserId(), - user.getUsername(), - user.getDeptId(), - user.getDataScopes(), - user.getAuthorities().stream() - .map(GrantedAuthority::getAuthority) - .collect(Collectors.toSet()) - ); + // 构建会话快照(不存密码) + SecurityUserDetails sessionUser = buildSessionUser(user); - // 存储访问令牌、刷新令牌和刷新令牌映射 - storeTokensInRedis(accessToken, refreshToken, userSession); - - // 单设备登录控制 + storeTokensInRedis(accessToken, refreshToken, sessionUser); handleSingleDeviceLogin(user.getUserId(), accessToken); return AuthenticationToken.builder() @@ -94,61 +66,24 @@ public class RedisTokenManager implements TokenManager { .build(); } - /** - * 根据 token 解析用户信息 - * - * @param token Redis Token - * @return 构建的 Authentication 对象 - */ @Override public Authentication parseToken(String token) { Object raw = redisTemplate.opsForValue().get(formatTokenKey(token)); if (raw == null) return null; - UserSession userSession = jsonMapper.convertValue(raw, UserSession.class); - - // 构建用户权限集合 - Set authorities = null; - - Set roles = userSession.getRoles(); - if (CollectionUtil.isNotEmpty(roles)) { - authorities = roles.stream() - .map(SimpleGrantedAuthority::new) - .collect(Collectors.toSet()); - } - - // 构建用户详情对象 - SysUserDetails userDetails = buildUserDetails(userSession, authorities); - return new UsernamePasswordAuthenticationToken(userDetails, null, authorities); + SecurityUserDetails userDetails = jsonMapper.convertValue(raw, SecurityUserDetails.class); + return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); } - /** - * 校验 Token 是否有效 - * - * @param token 访问令牌 - * @return 是否有效 - */ @Override public boolean validateToken(String token) { return redisTemplate.hasKey(formatTokenKey(token)); } - /** - * 校验 RefreshToken 是否有效 - * - * @param refreshToken 访问令牌 - * @return 是否有效 - */ @Override public boolean validateRefreshToken(String refreshToken) { return redisTemplate.hasKey(formatRefreshTokenKey(refreshToken)); } - /** - * 刷新令牌 - * - * @param refreshToken 刷新令牌 - * @return 新生成的 AuthenticationToken 对象 - */ @Override public AuthenticationToken refreshToken(String refreshToken) { Object raw = redisTemplate.opsForValue() @@ -156,16 +91,16 @@ public class RedisTokenManager implements TokenManager { if (raw == null) { throw new TokenInvalidException(ResultCode.REFRESH_TOKEN_INVALID); } - UserSession userSession = jsonMapper.convertValue(raw, UserSession.class); - Object oldAccessTokenValue = redisTemplate.opsForValue().get(StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userSession.getUserId())); - // 删除旧的访问令牌记录 + SecurityUserDetails sessionUser = jsonMapper.convertValue(raw, SecurityUserDetails.class); + + Object oldAccessTokenValue = redisTemplate.opsForValue() + .get(StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, sessionUser.getUserId())); Optional.of(oldAccessTokenValue) .map(String.class::cast) .ifPresent(oldAccessToken -> redisTemplate.delete(formatTokenKey(oldAccessToken))); - // 生成新访问令牌并存储 String newAccessToken = IdUtil.fastSimpleUUID(); - storeAccessToken(newAccessToken, userSession); + storeAccessToken(newAccessToken, sessionUser); int accessTtl = securityProperties.getSession().getAccessTokenTimeToLive(); return AuthenticationToken.builder() @@ -175,164 +110,91 @@ public class RedisTokenManager implements TokenManager { .build(); } - /** - * Make access token invalid - *

- * Only deletes the current token, not all sessions for the user. - * This ensures single-device logout doesn't affect other devices when allowMultiLogin=true. - * - * @param token Access token - */ @Override public void invalidateToken(String token) { String cleanToken = cleanBearerPrefix(token); - // Only delete the current token, not all user sessions redisTemplate.delete(formatTokenKey(cleanToken)); } - /** - * 使指定用户的所有会话失效 - *

- * 适用场景:用户修改密码、管理员强制下线、账号封禁等 - * - * @param userId 用户ID - */ @Override public void invalidateUserSessions(Long userId) { if (userId == null) { return; } - - // 1. 删除访问令牌相关 String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userId); Object accessTokenValue = redisTemplate.opsForValue().get(userAccessKey); if (accessTokenValue instanceof String accessToken) { redisTemplate.delete(formatTokenKey(accessToken)); } - // 无论是否存在访问令牌映射,都尝试删除 userAccessKey redisTemplate.delete(userAccessKey); - // 2. 删除刷新令牌相关 String userRefreshKey = StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, userId); Object refreshTokenValue = redisTemplate.opsForValue().get(userRefreshKey); if (refreshTokenValue instanceof String refreshToken) { redisTemplate.delete(StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken)); } - // 同样清理 userRefreshKey 本身 redisTemplate.delete(userRefreshKey); } - /** - * 将访问令牌和刷新令牌存储至 Redis - * - * @param accessToken 访问令牌 - * @param refreshToken 刷新令牌 - * @param userSession 用户会话信息 - */ - private void storeTokensInRedis(String accessToken, String refreshToken, UserSession userSession) { - // 访问令牌 -> 用户信息 - setRedisValue(formatTokenKey(accessToken), userSession, securityProperties.getSession().getAccessTokenTimeToLive()); + // ======================== private ======================== - // 刷新令牌 -> 用户信息 - String refreshTokenKey = StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken); - setRedisValue(refreshTokenKey, userSession, securityProperties.getSession().getRefreshTokenTimeToLive()); - - // 用户ID -> 刷新令牌 - setRedisValue(StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, userSession.getUserId()), - refreshToken, - securityProperties.getSession().getRefreshTokenTimeToLive()); + private SecurityUserDetails buildSessionUser(SecurityUserDetails user) { + SecurityUserDetails sessionUser = new SecurityUserDetails(); + sessionUser.setUserId(user.getUserId()); + sessionUser.setUsername(user.getUsername()); + sessionUser.setDeptId(user.getDeptId()); + sessionUser.setDataScopes(user.getDataScopes()); + sessionUser.setRoles(user.getRoles()); + sessionUser.setEnabled(user.isEnabled()); + sessionUser.setPassword(null); + return sessionUser; + } + + private void storeTokensInRedis(String accessToken, String refreshToken, SecurityUserDetails sessionUser) { + setRedisValue(formatTokenKey(accessToken), sessionUser, + securityProperties.getSession().getAccessTokenTimeToLive()); + String refreshTokenKey = StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken); + setRedisValue(refreshTokenKey, sessionUser, + securityProperties.getSession().getRefreshTokenTimeToLive()); + setRedisValue(StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, sessionUser.getUserId()), + refreshToken, securityProperties.getSession().getRefreshTokenTimeToLive()); } - /** - * 处理单设备登录控制 - *

- * 当配置不允许多设备登录时,新登录会使旧Token失效 - * - * @param userId 用户ID - * @param accessToken 新生成的访问令牌 - */ private void handleSingleDeviceLogin(Long userId, String accessToken) { Boolean allowMultiLogin = securityProperties.getSession().getRedisToken().getAllowMultiLogin(); String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userId); - // 单设备登录控制,删除旧的访问令牌 if (!allowMultiLogin) { Object oldAccessTokenValue = redisTemplate.opsForValue().get(userAccessKey); if (oldAccessTokenValue instanceof String oldAccessToken) { redisTemplate.delete(formatTokenKey(oldAccessToken)); } } - // 存储访问令牌映射(用户ID -> 访问令牌),用于单设备登录控制删除旧的访问令牌和刷新令牌时删除旧令牌 setRedisValue(userAccessKey, accessToken, securityProperties.getSession().getAccessTokenTimeToLive()); } - /** - * 存储新的访问令牌 - * - * @param newAccessToken 新访问令牌 - * @param userSession 用户会话信息 - */ - private void storeAccessToken(String newAccessToken, UserSession userSession) { - setRedisValue(StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, newAccessToken), userSession, securityProperties.getSession().getAccessTokenTimeToLive()); - String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userSession.getUserId()); + private void storeAccessToken(String newAccessToken, SecurityUserDetails sessionUser) { + setRedisValue(StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, newAccessToken), sessionUser, + securityProperties.getSession().getAccessTokenTimeToLive()); + String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, sessionUser.getUserId()); setRedisValue(userAccessKey, newAccessToken, securityProperties.getSession().getAccessTokenTimeToLive()); } - /** - * 构建用户详情对象 - * - * @param userSession 用户会话信息 - * @param authorities 权限集合 - * @return SysUserDetails 用户详情 - */ - private SysUserDetails buildUserDetails(UserSession userSession, Set authorities) { - SysUserDetails userDetails = new SysUserDetails(); - userDetails.setUserId(userSession.getUserId()); - userDetails.setUsername(userSession.getUsername()); - userDetails.setDeptId(userSession.getDeptId()); - userDetails.setDataScopes(userSession.getDataScopes()); - userDetails.setAuthorities(authorities); - return userDetails; - } - - /** - * 格式化访问令牌的 Redis 键 - * - * @param token 访问令牌 - * @return 格式化后的 Redis 键 - */ private String formatTokenKey(String token) { return StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, token); } - /** - * 格式化刷新令牌的 Redis 键 - * - * @param refreshToken 访问令牌 - * @return 格式化后的 Redis 键 - */ private String formatRefreshTokenKey(String refreshToken) { return StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken); } - /** - * 将值存储到 Redis - * - * @param key 键 - * @param value 值 - * @param ttl 过期时间(秒),-1表示永不过期 - */ private void setRedisValue(String key, Object value, int ttl) { if (ttl != -1) { redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.SECONDS); } else { - redisTemplate.opsForValue().set(key, value); // ttl=-1时永不过期 + redisTemplate.opsForValue().set(key, value); } } - - /** - * 清理 Bearer 前缀 - */ private String cleanBearerPrefix(String token) { if (token.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) { return token.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length()).trim(); diff --git a/src/main/java/com/youlai/boot/framework/security/util/SecurityUtils.java b/src/main/java/com/youlai/boot/framework/security/util/SecurityUtils.java index 3fed56b1..d4f28d3c 100644 --- a/src/main/java/com/youlai/boot/framework/security/util/SecurityUtils.java +++ b/src/main/java/com/youlai/boot/framework/security/util/SecurityUtils.java @@ -1,21 +1,16 @@ package com.youlai.boot.framework.security.util; -import cn.hutool.core.collection.CollectionUtil; -import cn.hutool.core.util.StrUtil; -import com.youlai.boot.common.constant.SecurityConstants; import com.youlai.boot.common.constant.SystemConstants; import com.youlai.boot.framework.security.model.RoleDataScope; -import com.youlai.boot.framework.security.model.SysUserDetails; +import com.youlai.boot.framework.security.model.SecurityUserDetails; import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.HttpHeaders; import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import java.util.*; -import java.util.stream.Collectors; /** * Spring Security 工具类 @@ -26,101 +21,73 @@ import java.util.stream.Collectors; public class SecurityUtils { /** - * 获取当前登录人信息 - * - * @return Optional + * 获取当前登录用户 */ - public static Optional getUser() { + public static Optional getUser() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null) { Object principal = authentication.getPrincipal(); - if (principal instanceof SysUserDetails) { - return Optional.of((SysUserDetails) principal); + if (principal instanceof SecurityUserDetails) { + return Optional.of((SecurityUserDetails) principal); } } return Optional.empty(); } - /** * 获取用户ID - * - * @return Long */ public static Long getUserId() { - return getUser().map(SysUserDetails::getUserId).orElse(null); + return getUser().map(SecurityUserDetails::getUserId).orElse(null); } - /** * 获取用户账号 - * - * @return String 用户账号 */ public static String getUsername() { - return getUser().map(SysUserDetails::getUsername).orElse(null); + return getUser().map(SecurityUserDetails::getUsername).orElse(null); } - /** * 获取部门ID - * - * @return Long */ public static Long getDeptId() { - return getUser().map(SysUserDetails::getDeptId).orElse(null); + return getUser().map(SecurityUserDetails::getDeptId).orElse(null); } /** * 获取数据权限列表 - * - * @return 数据权限列表 */ public static List getDataScopes() { - return getUser().map(SysUserDetails::getDataScopes).orElse(List.of()); + return getUser().map(SecurityUserDetails::getDataScopes).orElse(List.of()); } /** - * 获取角色集合 - * - * @return 角色集合 + * 获取角色编码集合,不带 ROLE_ 前缀。 + *

+ * 直接从 {@link SecurityUserDetails#getRoles()} 取值,不做 stripping 和过滤。 */ public static Set getRoles() { - return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication()) - .map(Authentication::getAuthorities) - .filter(CollectionUtil::isNotEmpty) - .stream() - .flatMap(Collection::stream) - .map(GrantedAuthority::getAuthority) - // 筛选角色,authorities 中的角色都是以 ROLE_ 开头 - .filter(authority -> authority.startsWith(SecurityConstants.ROLE_PREFIX)) - .map(authority -> StrUtil.removePrefix(authority, SecurityConstants.ROLE_PREFIX)) - .collect(Collectors.toSet()); + return getUser().map(SecurityUserDetails::getRoles).orElse(Set.of()); } /** * 是否超级管理员 - *

- * 超级管理员忽视任何权限判断 */ public static boolean isRoot() { - Set roles = getRoles(); - return roles.contains(SystemConstants.ROOT_ROLE_CODE); + return getRoles().contains(SystemConstants.ROOT_ROLE_CODE); } /** - * 获取请求中的 Token - * - * @return Token 字符串 + * 从请求头获取 Token */ public static String getAccessToken() { - ServletRequestAttributes servletRequestAttributes = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()); - if(Objects.isNull(servletRequestAttributes)) { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { return null; } - HttpServletRequest request = servletRequestAttributes.getRequest(); + HttpServletRequest request = attributes.getRequest(); return request.getHeader(HttpHeaders.AUTHORIZATION); } - } diff --git a/src/main/java/com/youlai/boot/framework/web/advice/GlobalExceptionHandler.java b/src/main/java/com/youlai/boot/framework/web/advice/GlobalExceptionHandler.java index 0692e0de..89c044ec 100644 --- a/src/main/java/com/youlai/boot/framework/web/advice/GlobalExceptionHandler.java +++ b/src/main/java/com/youlai/boot/framework/web/advice/GlobalExceptionHandler.java @@ -5,6 +5,7 @@ import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.common.result.Result; import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.framework.security.exception.TokenInvalidException; +import com.youlai.boot.framework.web.exception.RateLimitException; import jakarta.servlet.ServletException; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; @@ -157,6 +158,12 @@ public class GlobalExceptionHandler { return Result.failed(e.getResultCode()); } + @ExceptionHandler(RateLimitException.class) + @ResponseStatus(HttpStatus.TOO_MANY_REQUESTS) + public Result handleRateLimitException(RateLimitException e) { + return Result.failed(e.getResultCode(), e.getMessage()); + } + @ExceptionHandler(BusinessException.class) @ResponseStatus(HttpStatus.OK) public Result handleBizException(BusinessException e) { diff --git a/src/main/java/com/youlai/boot/framework/web/aspect/LogAspect.java b/src/main/java/com/youlai/boot/framework/web/aspect/LogAspect.java index 6642a503..00093330 100644 --- a/src/main/java/com/youlai/boot/framework/web/aspect/LogAspect.java +++ b/src/main/java/com/youlai/boot/framework/web/aspect/LogAspect.java @@ -3,7 +3,7 @@ package com.youlai.boot.framework.web.aspect; import cn.hutool.core.util.StrUtil; import cn.hutool.http.useragent.UserAgent; import cn.hutool.http.useragent.UserAgentUtil; -import com.youlai.boot.common.annotation.Log; +import com.youlai.boot.framework.annotation.Log; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; import com.youlai.boot.common.util.IPUtils; diff --git a/src/main/java/com/youlai/boot/framework/web/aspect/RateLimitAspect.java b/src/main/java/com/youlai/boot/framework/web/aspect/RateLimitAspect.java index 7c1940f1..a58fe6e2 100644 --- a/src/main/java/com/youlai/boot/framework/web/aspect/RateLimitAspect.java +++ b/src/main/java/com/youlai/boot/framework/web/aspect/RateLimitAspect.java @@ -2,13 +2,13 @@ package com.youlai.boot.framework.web.aspect; import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.digest.DigestUtil; -import com.youlai.boot.common.annotation.RateLimit; +import com.youlai.boot.framework.annotation.RateLimit; import com.youlai.boot.common.constant.RedisConstants; import com.youlai.boot.common.constant.SecurityConstants; -import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.common.util.IPUtils; import com.youlai.boot.framework.web.config.RateLimitProperties; +import com.youlai.boot.framework.web.exception.RateLimitException; import com.youlai.boot.framework.web.ratelimit.SlidingWindowScript; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -55,8 +55,9 @@ public class RateLimitAspect { String key = buildKey(request, rateLimit); int limit = rateLimit.limit() > 0 ? rateLimit.limit() : rateLimitProperties.getDefaultLimit(); - int window = rateLimit.window() > 0 ? rateLimit.window() : rateLimitProperties.getDefaultWindow(); - long windowMs = rateLimit.timeUnit().toMillis(window); + long windowMs = rateLimit.window() > 0 + ? rateLimit.window() * 1000L + : rateLimitProperties.getDefaultWindow().toMillis(); Long count = SlidingWindowScript.execute(redisTemplate, key, windowMs); @@ -65,7 +66,7 @@ public class RateLimitAspect { if (current > limit) { log.warn("接口限流触发 key={} count={} limit={}", key, current, limit); - throw new BusinessException(ResultCode.REQUEST_CONCURRENCY_LIMIT_EXCEEDED); + throw new RateLimitException(ResultCode.REQUEST_CONCURRENCY_LIMIT_EXCEEDED); } return jp.proceed(); @@ -73,8 +74,7 @@ public class RateLimitAspect { private String buildKey(HttpServletRequest request, RateLimit rateLimit) { String user = resolveUser(request); - return StrUtil.format(RedisConstants.RateLimiter.API, - rateLimit.prefix(), user, request.getRequestURI()); + return StrUtil.format(RedisConstants.RateLimit.API, user, request.getRequestURI()); } private String resolveUser(HttpServletRequest request) { diff --git a/src/main/java/com/youlai/boot/framework/web/aspect/RepeatSubmitAspect.java b/src/main/java/com/youlai/boot/framework/web/aspect/RepeatSubmitAspect.java index 63e2456c..dbea1303 100644 --- a/src/main/java/com/youlai/boot/framework/web/aspect/RepeatSubmitAspect.java +++ b/src/main/java/com/youlai/boot/framework/web/aspect/RepeatSubmitAspect.java @@ -2,11 +2,12 @@ package com.youlai.boot.framework.web.aspect; import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.digest.DigestUtil; +import cn.hutool.json.JSONUtil; import com.youlai.boot.common.constant.RedisConstants; import com.youlai.boot.common.constant.SecurityConstants; import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.common.exception.BusinessException; -import com.youlai.boot.common.annotation.RepeatSubmit; +import com.youlai.boot.framework.annotation.RepeatSubmit; import com.youlai.boot.common.util.IPUtils; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; @@ -42,7 +43,7 @@ public class RepeatSubmitAspect { @Around(value = "repeatSubmitPointCut(repeatSubmit)", argNames = "pjp,repeatSubmit") public Object handleRepeatSubmit(ProceedingJoinPoint pjp, RepeatSubmit repeatSubmit) throws Throwable { - String lockKey = buildLockKey(); + String lockKey = buildLockKey(pjp); int expire = repeatSubmit.expire(); RLock lock = redissonClient.getLock(lockKey); @@ -56,12 +57,17 @@ public class RepeatSubmitAspect { /** * 生成防重复提交锁的 key + *

+ * key 由「用户标识 + 接口(method:URI) + 请求体哈希」组成:只有同一用户在同一接口提交 + * 完全相同的请求体才视为重复提交;若仅按 method:URI 生成,同一接口的不同提交 + * (如新增菜单与新增目录)会在窗口内被误判为重复。 */ - private String buildLockKey() { + private String buildLockKey(ProceedingJoinPoint pjp) { HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); String userIdentifier = getUserIdentifier(request); String requestIdentifier = StrUtil.join(":", request.getMethod(), request.getRequestURI()); - return StrUtil.format(RedisConstants.Lock.RESUBMIT, userIdentifier, requestIdentifier); + String bodyHash = DigestUtil.sha256Hex(JSONUtil.toJsonStr(pjp.getArgs())); + return StrUtil.format(RedisConstants.Lock.RESUBMIT, userIdentifier, requestIdentifier + ":" + bodyHash); } /** diff --git a/src/main/java/com/youlai/boot/framework/web/config/RateLimitProperties.java b/src/main/java/com/youlai/boot/framework/web/config/RateLimitProperties.java index a62102dc..72d8c5f4 100644 --- a/src/main/java/com/youlai/boot/framework/web/config/RateLimitProperties.java +++ b/src/main/java/com/youlai/boot/framework/web/config/RateLimitProperties.java @@ -4,11 +4,11 @@ import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; +import java.time.Duration; + /** * 限流配置 - *

- * 对应 application.yml 中的 {@code rate-limit} 配置节点。 - *

+ *

对应 application.yml 中的 {@code rate-limit} 配置节点。

* * @author Ray.Hao * @since 4.3.1 @@ -19,14 +19,14 @@ import org.springframework.stereotype.Component; public class RateLimitProperties { /** - * @RateLimit 注解未显式指定 limit 时的默认阈值 + * @RateLimit 注解未显式指定 limit 时的默认阈值(窗口内最大请求数) */ private int defaultLimit = 5; /** - * @RateLimit 注解未显式指定 window 时的默认窗口大小(秒) + * @RateLimit 注解未显式指定 window 时的默认窗口大小,支持 60s / 1m / 500ms 等 Duration 写法 */ - private int defaultWindow = 1; + private Duration defaultWindow = Duration.ofSeconds(60); /** * IP 全局限流配置 @@ -48,9 +48,9 @@ public class RateLimitProperties { private int limit = 1000; /** - * 窗口大小(秒,默认 60) + * 滑动窗口大小,支持 60s / 1m / 500ms 等 Duration 写法(默认 60s) */ - private int windowSeconds = 60; + private Duration window = Duration.ofSeconds(60); } diff --git a/src/main/java/com/youlai/boot/framework/web/exception/RateLimitException.java b/src/main/java/com/youlai/boot/framework/web/exception/RateLimitException.java new file mode 100644 index 00000000..9374b6ba --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/web/exception/RateLimitException.java @@ -0,0 +1,26 @@ +package com.youlai.boot.framework.web.exception; + +import com.youlai.boot.common.result.ResultCode; +import lombok.Getter; + +/** + * 接口限流异常:请求超出限流阈值时抛出,由全局异常处理器统一映射为 HTTP 429。 + * + * @author Ray.Hao + * @since 4.4.0 + */ +@Getter +public class RateLimitException extends RuntimeException { + + private final ResultCode resultCode; + + public RateLimitException(ResultCode resultCode) { + super(resultCode.getMsg()); + this.resultCode = resultCode; + } + + public RateLimitException(ResultCode resultCode, String message) { + super(message); + this.resultCode = resultCode; + } +} diff --git a/src/main/java/com/youlai/boot/framework/web/filter/IpRateLimitFilter.java b/src/main/java/com/youlai/boot/framework/web/filter/IpRateLimitFilter.java index a05d8f99..b4e3f0a1 100644 --- a/src/main/java/com/youlai/boot/framework/web/filter/IpRateLimitFilter.java +++ b/src/main/java/com/youlai/boot/framework/web/filter/IpRateLimitFilter.java @@ -30,7 +30,7 @@ import java.io.IOException; * *

限流维度

*
{@code
- *   Key: rate_limiter:ip:{clientIp}
+ *   Key: rate_limit:ip:{clientIp}
  *   默认: 1000 req / 60s(可通过 rate-limit.ip.* 配置)
  * }
* @@ -67,11 +67,17 @@ public class IpRateLimitFilter extends OncePerRequestFilter { } String ip = IPUtils.getIpAddr(request); - String key = StrUtil.format(RedisConstants.RateLimiter.IP, ip); - long windowMs = ipConfig.getWindowSeconds() * 1000L; + String key = StrUtil.format(RedisConstants.RateLimit.IP, ip); + long windowMs = ipConfig.getWindow().toMillis(); - // 执行滑动窗口计数(Lua 原子操作) - Long count = SlidingWindowScript.execute(redisTemplate, key, windowMs); + // 执行滑动窗口计数(Lua 原子操作);Redis 异常时 Fail-Open 放行 + Long count; + try { + count = SlidingWindowScript.execute(redisTemplate, key, windowMs); + } catch (Exception e) { + log.warn("IP 限流 Redis 异常,Fail-Open 放行 ip={}", ip, e); + count = null; + } int limit = ipConfig.getLimit(); int current = count != null ? count.intValue() : 0; @@ -80,7 +86,7 @@ public class IpRateLimitFilter extends OncePerRequestFilter { if (current > limit) { log.warn("IP 限流触发 ip={} count={} limit={}", ip, current, limit); - response.setHeader("Retry-After", String.valueOf(ipConfig.getWindowSeconds())); + response.setHeader("Retry-After", String.valueOf(ipConfig.getWindow().getSeconds())); ResponseWriter.writeError(response, ResultCode.REQUEST_CONCURRENCY_LIMIT_EXCEEDED); return; } diff --git a/src/main/java/com/youlai/boot/framework/web/ratelimit/SlidingWindowScript.java b/src/main/java/com/youlai/boot/framework/web/ratelimit/SlidingWindowScript.java index a05bc25e..fafe61ad 100644 --- a/src/main/java/com/youlai/boot/framework/web/ratelimit/SlidingWindowScript.java +++ b/src/main/java/com/youlai/boot/framework/web/ratelimit/SlidingWindowScript.java @@ -49,7 +49,7 @@ public final class SlidingWindowScript { * 执行滑动窗口计数 * * @param redisTemplate Redis 模板 - * @param key 限流 Key(如 {@code rate_limiter:ip:192.168.1.1}) + * @param key 限流 Key(如 {@code rate_limit:ip:192.168.1.1}) * @param windowMs 窗口大小(毫秒) * @return 当前窗口内请求数(包含本次) */ diff --git a/src/main/java/com/youlai/boot/framework/web/util/ResponseWriter.java b/src/main/java/com/youlai/boot/framework/web/util/ResponseWriter.java index 02f972ef..118ea7ed 100644 --- a/src/main/java/com/youlai/boot/framework/web/util/ResponseWriter.java +++ b/src/main/java/com/youlai/boot/framework/web/util/ResponseWriter.java @@ -57,6 +57,7 @@ public final class ResponseWriter { return switch (resultCode) { case ACCESS_UNAUTHORIZED, ACCESS_TOKEN_INVALID, REFRESH_TOKEN_INVALID -> HttpStatus.UNAUTHORIZED.value(); case ACCESS_PERMISSION_EXCEPTION -> HttpStatus.FORBIDDEN.value(); + case REQUEST_CONCURRENCY_LIMIT_EXCEEDED -> HttpStatus.TOO_MANY_REQUESTS.value(); default -> HttpStatus.BAD_REQUEST.value(); }; } diff --git a/src/main/java/com/youlai/boot/message/controller/SseController.java b/src/main/java/com/youlai/boot/message/controller/SseController.java index f663c96b..3135a842 100644 --- a/src/main/java/com/youlai/boot/message/controller/SseController.java +++ b/src/main/java/com/youlai/boot/message/controller/SseController.java @@ -1,7 +1,7 @@ package com.youlai.boot.message.controller; import com.youlai.boot.common.result.Result; -import com.youlai.boot.framework.security.model.SysUserDetails; +import com.youlai.boot.framework.security.model.SecurityUserDetails; import com.youlai.boot.framework.security.util.SecurityUtils; import com.youlai.boot.message.service.SseService; import io.swagger.v3.oas.annotations.Operation; @@ -29,7 +29,7 @@ public class SseController { @Operation(summary = "建立SSE连接") @GetMapping(value = "/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter connect() { - SysUserDetails user = SecurityUtils.getUser().orElse(null); + SecurityUserDetails user = SecurityUtils.getUser().orElse(null); if (user == null) { log.warn("SSE连接失败:未获取到当前用户"); return null; diff --git a/src/main/java/com/youlai/boot/message/event/DictChangeEvent.java b/src/main/java/com/youlai/boot/message/event/DictChangeEvent.java deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/com/youlai/boot/system/controller/ConfigController.java b/src/main/java/com/youlai/boot/system/controller/ConfigController.java index e68280a8..d4559240 100644 --- a/src/main/java/com/youlai/boot/system/controller/ConfigController.java +++ b/src/main/java/com/youlai/boot/system/controller/ConfigController.java @@ -5,7 +5,7 @@ import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; import com.youlai.boot.common.result.PageResult; import com.youlai.boot.common.result.Result; -import com.youlai.boot.common.annotation.Log; +import com.youlai.boot.framework.annotation.Log; import com.youlai.boot.system.model.form.ConfigForm; import com.youlai.boot.system.model.query.ConfigQuery; import com.youlai.boot.system.model.vo.ConfigVO; @@ -53,11 +53,11 @@ public class ConfigController { } @Operation(summary = "获取系统配置表单数据") - @GetMapping("/{id}/form") + @GetMapping("/{configId}/form") public Result getConfigForm( - @Parameter(description = "系统配置ID") @PathVariable Long id + @Parameter(description = "系统配置ID") @PathVariable Long configId ) { - ConfigForm formData = configService.getConfigFormData(id); + ConfigForm formData = configService.getConfigFormData(configId); return Result.success(formData); } @@ -70,19 +70,19 @@ public class ConfigController { } @Operation(summary = "修改系统配置") - @PutMapping(value = "/{id}") + @PutMapping(value = "/{configId}") @PreAuthorize("@ss.hasPerm('sys:config:update')") @Log(module = LogModuleEnum.CONFIG, value = ActionTypeEnum.UPDATE) - public Result update(@Valid @PathVariable Long id, @RequestBody ConfigForm configForm) { - return Result.judge(configService.edit(id, configForm)); + public Result update(@Valid @PathVariable Long configId, @RequestBody ConfigForm configForm) { + return Result.judge(configService.edit(configId, configForm)); } @Operation(summary = "删除系统配置") - @DeleteMapping("/{id}") + @DeleteMapping("/{configId}") @PreAuthorize("@ss.hasPerm('sys:config:delete')") @Log(module = LogModuleEnum.CONFIG, value = ActionTypeEnum.DELETE) - public Result delete(@PathVariable Long id) { - return Result.judge(configService.delete(id)); + public Result delete(@PathVariable Long configId) { + return Result.judge(configService.delete(configId)); } } diff --git a/src/main/java/com/youlai/boot/system/controller/DeptController.java b/src/main/java/com/youlai/boot/system/controller/DeptController.java index 7a54a8fd..2475b702 100644 --- a/src/main/java/com/youlai/boot/system/controller/DeptController.java +++ b/src/main/java/com/youlai/boot/system/controller/DeptController.java @@ -2,13 +2,13 @@ package com.youlai.boot.system.controller; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; -import com.youlai.boot.common.annotation.RepeatSubmit; +import com.youlai.boot.framework.annotation.RepeatSubmit; import com.youlai.boot.common.model.Option; import com.youlai.boot.common.result.Result; import com.youlai.boot.system.model.form.DeptForm; import com.youlai.boot.system.model.query.DeptQuery; import com.youlai.boot.system.model.vo.DeptVO; -import com.youlai.boot.common.annotation.Log; +import com.youlai.boot.framework.annotation.Log; import com.youlai.boot.system.service.DeptService; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; diff --git a/src/main/java/com/youlai/boot/system/controller/DictController.java b/src/main/java/com/youlai/boot/system/controller/DictController.java index ef55538d..c905411e 100644 --- a/src/main/java/com/youlai/boot/system/controller/DictController.java +++ b/src/main/java/com/youlai/boot/system/controller/DictController.java @@ -12,9 +12,9 @@ import com.youlai.boot.system.model.query.DictQuery; import com.youlai.boot.system.model.vo.DictItemOptionVO; import com.youlai.boot.system.model.vo.DictItemPageVO; import com.youlai.boot.system.model.vo.DictPageVO; -import com.youlai.boot.common.annotation.RepeatSubmit; +import com.youlai.boot.framework.annotation.RepeatSubmit; import com.youlai.boot.system.model.form.DictForm; -import com.youlai.boot.common.annotation.Log; +import com.youlai.boot.framework.annotation.Log; import com.youlai.boot.system.service.DictItemService; import com.youlai.boot.system.service.DictService; import com.youlai.boot.message.service.SseService; @@ -67,11 +67,11 @@ public class DictController { } @Operation(summary = "获取字典表单数据") - @GetMapping("/{id}/form") + @GetMapping("/{dictId}/form") public Result getDictForm( - @Parameter(description = "字典ID") @PathVariable Long id + @Parameter(description = "字典ID") @PathVariable Long dictId ) { - DictForm formData = dictService.getDictForm(id); + DictForm formData = dictService.getDictForm(dictId); return Result.success(formData); } @@ -90,14 +90,14 @@ public class DictController { } @Operation(summary = "修改字典") - @PutMapping("/{id}") + @PutMapping("/{dictId}") @PreAuthorize("@ss.hasPerm('sys:dict:update')") @Log(module = LogModuleEnum.DICT, value = ActionTypeEnum.UPDATE) public Result updateDict( - @PathVariable Long id, + @PathVariable Long dictId, @RequestBody DictForm dictForm ) { - boolean status = dictService.updateDict(id, dictForm); + boolean status = dictService.updateDict(dictId, dictForm); // 发送字典更新通知 if (status && dictForm.getDictCode() != null) { sseService.sendDictChange(dictForm.getDictCode()); diff --git a/src/main/java/com/youlai/boot/system/controller/HealthController.java b/src/main/java/com/youlai/boot/system/controller/HealthController.java new file mode 100644 index 00000000..09b0652d --- /dev/null +++ b/src/main/java/com/youlai/boot/system/controller/HealthController.java @@ -0,0 +1,26 @@ +package com.youlai.boot.system.controller; + +import com.youlai.boot.common.result.Result; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 系统健康检查接口,返回 ok。 + * 不标注 @RateLimit,仅受 IP 全局限流层约束,用作该层的验证端点。 + */ +@Tag(name = "00.系统健康检查") +@RestController +@RequestMapping("/api/v1/health") +@Slf4j +public class HealthController { + + @Operation(summary = "健康检查", description = "返回 ok,供限流测试与探针使用") + @GetMapping + public Result health() { + return Result.success("ok"); + } +} diff --git a/src/main/java/com/youlai/boot/system/controller/LogController.java b/src/main/java/com/youlai/boot/system/controller/LogController.java index c717b80e..34d70307 100644 --- a/src/main/java/com/youlai/boot/system/controller/LogController.java +++ b/src/main/java/com/youlai/boot/system/controller/LogController.java @@ -1,7 +1,7 @@ package com.youlai.boot.system.controller; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.youlai.boot.common.annotation.Log; +import com.youlai.boot.framework.annotation.Log; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; import com.youlai.boot.common.result.PageResult; diff --git a/src/main/java/com/youlai/boot/system/controller/MenuController.java b/src/main/java/com/youlai/boot/system/controller/MenuController.java index 1050803c..5000344f 100644 --- a/src/main/java/com/youlai/boot/system/controller/MenuController.java +++ b/src/main/java/com/youlai/boot/system/controller/MenuController.java @@ -1,7 +1,7 @@ package com.youlai.boot.system.controller; -import com.youlai.boot.common.annotation.Log; -import com.youlai.boot.common.annotation.RepeatSubmit; +import com.youlai.boot.framework.annotation.Log; +import com.youlai.boot.framework.annotation.RepeatSubmit; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; import com.youlai.boot.common.model.Option; @@ -62,12 +62,12 @@ public class MenuController { } @Operation(summary = "菜单表单数据") - @GetMapping("/{id}/form") + @GetMapping("/{menuId}/form") @PreAuthorize("@ss.hasPerm('sys:menu:update')") public Result getMenuForm( - @Parameter(description = "菜单ID") @PathVariable Long id + @Parameter(description = "菜单ID") @PathVariable Long menuId ) { - MenuForm menu = menuService.getMenuForm(id); + MenuForm menu = menuService.getMenuForm(menuId); return Result.success(menu); } @@ -82,10 +82,11 @@ public class MenuController { } @Operation(summary = "修改菜单") - @PutMapping(value = "/{id}") + @PutMapping(value = "/{menuId}") @PreAuthorize("@ss.hasPerm('sys:menu:update')") @Log(module = LogModuleEnum.MENU, value = ActionTypeEnum.UPDATE) public Result updateMenu( + @Parameter(description = "菜单ID") @PathVariable Long menuId, @RequestBody MenuForm menuForm ) { boolean result = menuService.saveMenu(menuForm); @@ -93,13 +94,13 @@ public class MenuController { } @Operation(summary = "删除菜单") - @DeleteMapping("/{id}") + @DeleteMapping("/{menuId}") @PreAuthorize("@ss.hasPerm('sys:menu:delete')") @Log(module = LogModuleEnum.MENU, value = ActionTypeEnum.DELETE) public Result deleteMenu( - @Parameter(description = "菜单ID,多个以英文(,)分割") @PathVariable("id") Long id + @Parameter(description = "菜单ID,多个以英文(,)分割") @PathVariable Long menuId ) { - boolean result = menuService.deleteMenu(id); + boolean result = menuService.deleteMenu(menuId); return Result.judge(result); } diff --git a/src/main/java/com/youlai/boot/system/controller/NoticeController.java b/src/main/java/com/youlai/boot/system/controller/NoticeController.java index eafcad68..95e38a98 100644 --- a/src/main/java/com/youlai/boot/system/controller/NoticeController.java +++ b/src/main/java/com/youlai/boot/system/controller/NoticeController.java @@ -1,7 +1,7 @@ package com.youlai.boot.system.controller; import com.baomidou.mybatisplus.core.metadata.IPage; -import com.youlai.boot.common.annotation.Log; +import com.youlai.boot.framework.annotation.Log; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; import com.youlai.boot.common.result.PageResult; @@ -57,55 +57,55 @@ public class NoticeController { } @Operation(summary = "获取通知公告表单数据") - @GetMapping("/{id}/form") + @GetMapping("/{noticeId}/form") @PreAuthorize("@ss.hasPerm('sys:notice:update')") public Result getNoticeForm( - @Parameter(description = "通知公告ID") @PathVariable Long id + @Parameter(description = "通知公告ID") @PathVariable Long noticeId ) { - NoticeForm formData = noticeService.getNoticeFormData(id); + NoticeForm formData = noticeService.getNoticeFormData(noticeId); return Result.success(formData); } @Operation(summary = "阅读获取通知公告详情") - @GetMapping("/{id}/detail") + @GetMapping("/{noticeId}/detail") public Result getNoticeDetail( - @Parameter(description = "通知公告ID") @PathVariable Long id + @Parameter(description = "通知公告ID") @PathVariable Long noticeId ) { - NoticeDetailVO detailVo = noticeService.getNoticeDetail(id); + NoticeDetailVO detailVo = noticeService.getNoticeDetail(noticeId); return Result.success(detailVo); } @Operation(summary = "修改通知公告") - @PutMapping(value = "/{id}") + @PutMapping(value = "/{noticeId}") @PreAuthorize("@ss.hasPerm('sys:notice:update')") @Log(module = LogModuleEnum.NOTICE, value = ActionTypeEnum.UPDATE) public Result updateNotice( - @Parameter(description = "通知公告ID") @PathVariable Long id, + @Parameter(description = "通知公告ID") @PathVariable Long noticeId, @RequestBody @Validated NoticeForm formData ) { - boolean result = noticeService.updateNotice(id, formData); + boolean result = noticeService.updateNotice(noticeId, formData); return Result.judge(result); } @Operation(summary = "发布通知公告") - @PutMapping("/{id}/publish") + @PutMapping("/{noticeId}/publish") @PreAuthorize("@ss.hasPerm('sys:notice:publish')") @Log(module = LogModuleEnum.NOTICE, value = ActionTypeEnum.UPDATE) public Result publishNotice( - @Parameter(description = "通知公告ID") @PathVariable Long id + @Parameter(description = "通知公告ID") @PathVariable Long noticeId ) { - boolean result = noticeService.publishNotice(id); + boolean result = noticeService.publishNotice(noticeId); return Result.judge(result); } @Operation(summary = "撤回通知公告") - @PutMapping("/{id}/revoke") + @PutMapping("/{noticeId}/revoke") @PreAuthorize("@ss.hasPerm('sys:notice:revoke')") @Log(module = LogModuleEnum.NOTICE, value = ActionTypeEnum.UPDATE) public Result revokeNotice( - @Parameter(description = "通知公告ID") @PathVariable Long id + @Parameter(description = "通知公告ID") @PathVariable Long noticeId ) { - boolean result = noticeService.revokeNotice(id); + boolean result = noticeService.revokeNotice(noticeId); return Result.judge(result); } diff --git a/src/main/java/com/youlai/boot/system/controller/RoleController.java b/src/main/java/com/youlai/boot/system/controller/RoleController.java index edc7665a..be215f53 100644 --- a/src/main/java/com/youlai/boot/system/controller/RoleController.java +++ b/src/main/java/com/youlai/boot/system/controller/RoleController.java @@ -3,14 +3,14 @@ package com.youlai.boot.system.controller; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; -import com.youlai.boot.common.annotation.RepeatSubmit; +import com.youlai.boot.framework.annotation.RepeatSubmit; import com.youlai.boot.common.model.Option; import com.youlai.boot.common.result.PageResult; import com.youlai.boot.common.result.Result; import com.youlai.boot.system.model.form.RoleForm; import com.youlai.boot.system.model.query.RoleQuery; import com.youlai.boot.system.model.vo.RolePageVO; -import com.youlai.boot.common.annotation.Log; +import com.youlai.boot.framework.annotation.Log; import com.youlai.boot.system.service.RoleService; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -75,10 +75,12 @@ public class RoleController { } @Operation(summary = "修改角色") - @PutMapping(value = "/{id}") + @PutMapping(value = "/{roleId}") @PreAuthorize("@ss.hasPerm('sys:role:update')") @Log(module = LogModuleEnum.ROLE, value = ActionTypeEnum.UPDATE) - public Result updateRole(@Valid @RequestBody RoleForm roleForm) { + public Result updateRole( + @Parameter(description = "角色ID") @PathVariable Long roleId, + @Valid @RequestBody RoleForm roleForm) { boolean result = roleService.saveRole(roleForm); return Result.judge(result); } diff --git a/src/main/java/com/youlai/boot/system/controller/UserController.java b/src/main/java/com/youlai/boot/system/controller/UserController.java index ce9a926c..f9deca1b 100644 --- a/src/main/java/com/youlai/boot/system/controller/UserController.java +++ b/src/main/java/com/youlai/boot/system/controller/UserController.java @@ -2,8 +2,8 @@ package com.youlai.boot.system.controller; import cn.idev.excel.EasyExcel; import cn.idev.excel.ExcelWriter; -import com.youlai.boot.common.annotation.Log; -import com.youlai.boot.common.annotation.RepeatSubmit; +import com.youlai.boot.framework.annotation.Log; +import com.youlai.boot.framework.annotation.RepeatSubmit; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; import com.youlai.boot.common.enums.StatusEnum; diff --git a/src/main/java/com/youlai/boot/system/mapper/DeptMapper.java b/src/main/java/com/youlai/boot/system/mapper/DeptMapper.java index e00f27bf..f92e6ae6 100644 --- a/src/main/java/com/youlai/boot/system/mapper/DeptMapper.java +++ b/src/main/java/com/youlai/boot/system/mapper/DeptMapper.java @@ -3,7 +3,7 @@ package com.youlai.boot.system.mapper; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.toolkit.Constants; -import com.youlai.boot.common.annotation.DataPermission; +import com.youlai.boot.framework.annotation.DataPermission; import com.youlai.boot.system.model.entity.Dept; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; diff --git a/src/main/java/com/youlai/boot/system/mapper/UserMapper.java b/src/main/java/com/youlai/boot/system/mapper/UserMapper.java index d07fdcad..1da5402d 100644 --- a/src/main/java/com/youlai/boot/system/mapper/UserMapper.java +++ b/src/main/java/com/youlai/boot/system/mapper/UserMapper.java @@ -5,8 +5,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.youlai.boot.system.model.entity.SysUser; import com.youlai.boot.system.model.query.UserQuery; import com.youlai.boot.system.model.form.UserForm; -import com.youlai.boot.common.annotation.DataPermission; -import com.youlai.boot.framework.security.model.UserAuthInfo; +import com.youlai.boot.framework.annotation.DataPermission; +import com.youlai.boot.framework.security.model.SecurityUser; import com.youlai.boot.system.model.vo.UserExportVO; import com.youlai.boot.system.model.vo.UserPageVO; import com.youlai.boot.system.model.vo.UserProfileVO; @@ -48,11 +48,7 @@ public interface UserMapper extends BaseMapper { * @param username 用户名 * @return 认证信息 */ - UserAuthInfo getAuthInfoByUsername(String username); - - default UserAuthInfo getAuthCredentialsByUsername(String username) { - return getAuthInfoByUsername(username); - } + SecurityUser getAuthInfoByUsername(String username); /** * 根据手机号获取用户认证信息 @@ -60,11 +56,7 @@ public interface UserMapper extends BaseMapper { * @param mobile 手机号 * @return 认证信息 */ - UserAuthInfo getAuthInfoByMobile(String mobile); - - default UserAuthInfo getAuthCredentialsByMobile(String mobile) { - return getAuthInfoByMobile(mobile); - } + SecurityUser getAuthInfoByMobile(String mobile); /** * 获取导出用户列表 diff --git a/src/main/java/com/youlai/boot/system/mapper/UserSocialMapper.java b/src/main/java/com/youlai/boot/system/mapper/UserSocialMapper.java index 430075d3..0f50ccbb 100644 --- a/src/main/java/com/youlai/boot/system/mapper/UserSocialMapper.java +++ b/src/main/java/com/youlai/boot/system/mapper/UserSocialMapper.java @@ -1,7 +1,7 @@ package com.youlai.boot.system.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.youlai.boot.framework.security.model.UserAuthInfo; +import com.youlai.boot.framework.security.model.SecurityUser; import com.youlai.boot.system.model.entity.UserSocial; import org.apache.ibatis.annotations.Mapper; @@ -17,6 +17,6 @@ public interface UserSocialMapper extends BaseMapper { * @param userId 用户ID * @return 认证信息 */ - UserAuthInfo getAuthInfoByUserId(Long userId); + SecurityUser getAuthInfoByUserId(Long userId); } diff --git a/src/main/java/com/youlai/boot/system/model/entity/Config.java b/src/main/java/com/youlai/boot/system/model/entity/Config.java index c2b26317..586901a7 100644 --- a/src/main/java/com/youlai/boot/system/model/entity/Config.java +++ b/src/main/java/com/youlai/boot/system/model/entity/Config.java @@ -1,7 +1,7 @@ package com.youlai.boot.system.model.entity; import com.baomidou.mybatisplus.annotation.*; -import com.youlai.boot.common.base.BaseEntity; +import com.youlai.boot.framework.base.BaseEntity; import lombok.Data; import io.swagger.v3.oas.annotations.media.Schema; import lombok.EqualsAndHashCode; @@ -36,16 +36,19 @@ public class Config extends BaseEntity { /** * 描述、备注 */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark; /** * 创建人ID */ + @TableField(fill = FieldFill.INSERT) private Long createBy; /** * 更新人ID */ + @TableField(fill = FieldFill.INSERT_UPDATE) private Long updateBy; /** diff --git a/src/main/java/com/youlai/boot/system/model/entity/Dept.java b/src/main/java/com/youlai/boot/system/model/entity/Dept.java index 9c95c6a3..44517148 100644 --- a/src/main/java/com/youlai/boot/system/model/entity/Dept.java +++ b/src/main/java/com/youlai/boot/system/model/entity/Dept.java @@ -1,9 +1,9 @@ package com.youlai.boot.system.model.entity; +import com.baomidou.mybatisplus.annotation.FieldFill; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.youlai.boot.common.base.BaseEntity; +import com.youlai.boot.framework.base.BaseEntity; import lombok.Data; import lombok.EqualsAndHashCode; @@ -51,11 +51,13 @@ public class Dept extends BaseEntity { /** * 创建人 ID */ + @TableField(fill = FieldFill.INSERT) private Long createBy; /** * 更新人 ID */ + @TableField(fill = FieldFill.INSERT_UPDATE) private Long updateBy; /** diff --git a/src/main/java/com/youlai/boot/system/model/entity/Dict.java b/src/main/java/com/youlai/boot/system/model/entity/Dict.java index b011aca1..42b47bce 100644 --- a/src/main/java/com/youlai/boot/system/model/entity/Dict.java +++ b/src/main/java/com/youlai/boot/system/model/entity/Dict.java @@ -1,7 +1,7 @@ package com.youlai.boot.system.model.entity; -import com.baomidou.mybatisplus.annotation.TableName; -import com.youlai.boot.common.base.BaseEntity; +import com.baomidou.mybatisplus.annotation.*; +import com.youlai.boot.framework.base.BaseEntity; import lombok.Data; import lombok.EqualsAndHashCode; @@ -35,6 +35,7 @@ public class Dict extends BaseEntity { /** * 备注 */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark; /** diff --git a/src/main/java/com/youlai/boot/system/model/entity/DictItem.java b/src/main/java/com/youlai/boot/system/model/entity/DictItem.java index afaafb59..fe066941 100644 --- a/src/main/java/com/youlai/boot/system/model/entity/DictItem.java +++ b/src/main/java/com/youlai/boot/system/model/entity/DictItem.java @@ -1,7 +1,7 @@ package com.youlai.boot.system.model.entity; -import com.baomidou.mybatisplus.annotation.TableName; -import com.youlai.boot.common.base.BaseEntity; +import com.baomidou.mybatisplus.annotation.*; +import com.youlai.boot.framework.base.BaseEntity; import lombok.Data; import lombok.EqualsAndHashCode; @@ -44,20 +44,24 @@ public class DictItem extends BaseEntity { /** * 备注 */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark; /** * 标签类型 */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String tagType; /** * 创建人 ID */ + @TableField(fill = FieldFill.INSERT) private Long createBy; /** * 更新人 ID */ + @TableField(fill = FieldFill.INSERT_UPDATE) private Long updateBy; } \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/system/model/entity/Menu.java b/src/main/java/com/youlai/boot/system/model/entity/Menu.java index f7c0fab4..4f3fdac5 100644 --- a/src/main/java/com/youlai/boot/system/model/entity/Menu.java +++ b/src/main/java/com/youlai/boot/system/model/entity/Menu.java @@ -2,7 +2,7 @@ package com.youlai.boot.system.model.entity; import com.baomidou.mybatisplus.annotation.*; import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; -import com.youlai.boot.common.base.BaseEntity; +import com.youlai.boot.framework.base.BaseEntity; import lombok.Data; import lombok.EqualsAndHashCode; @@ -36,6 +36,7 @@ public class Menu extends BaseEntity { /** * 路由名称(Vue Router 中定义的路由名称) */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String routeName; /** @@ -46,16 +47,19 @@ public class Menu extends BaseEntity { /** * 组件路径(vue页面完整路径,省略.vue后缀) */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String component; /** * 外链地址 */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String externalUrl; /** * 权限标识 */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String perm; /** @@ -71,11 +75,13 @@ public class Menu extends BaseEntity { /** * 菜单图标 */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String icon; /** * 跳转路径 */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String redirect; /** diff --git a/src/main/java/com/youlai/boot/system/model/entity/Notice.java b/src/main/java/com/youlai/boot/system/model/entity/Notice.java index 9234808d..345bde11 100644 --- a/src/main/java/com/youlai/boot/system/model/entity/Notice.java +++ b/src/main/java/com/youlai/boot/system/model/entity/Notice.java @@ -1,9 +1,7 @@ package com.youlai.boot.system.model.entity; -import com.baomidou.mybatisplus.annotation.TableField; -import com.baomidou.mybatisplus.annotation.TableName; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.youlai.boot.common.base.BaseEntity; +import com.baomidou.mybatisplus.annotation.*; +import com.youlai.boot.framework.base.BaseEntity; import lombok.Data; import lombok.EqualsAndHashCode; @@ -55,6 +53,7 @@ public class Notice extends BaseEntity { /** * 目标用户ID集合 */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String targetUserIds; /** @@ -75,11 +74,13 @@ public class Notice extends BaseEntity { /** * 创建人ID */ + @TableField(fill = FieldFill.INSERT) private Long createBy; /** * 更新人ID */ + @TableField(fill = FieldFill.INSERT_UPDATE) private Long updateBy; /** diff --git a/src/main/java/com/youlai/boot/system/model/entity/Role.java b/src/main/java/com/youlai/boot/system/model/entity/Role.java index d4f17ee8..130c96f3 100644 --- a/src/main/java/com/youlai/boot/system/model/entity/Role.java +++ b/src/main/java/com/youlai/boot/system/model/entity/Role.java @@ -1,9 +1,9 @@ package com.youlai.boot.system.model.entity; +import com.baomidou.mybatisplus.annotation.FieldFill; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.youlai.boot.common.base.BaseEntity; +import com.youlai.boot.framework.base.BaseEntity; import lombok.Data; import lombok.EqualsAndHashCode; @@ -46,11 +46,13 @@ public class Role extends BaseEntity { /** * 创建人 ID */ + @TableField(fill = FieldFill.INSERT) private Long createBy; /** * 更新人 ID */ + @TableField(fill = FieldFill.INSERT_UPDATE) private Long updateBy; /** diff --git a/src/main/java/com/youlai/boot/system/model/entity/RoleMenu.java b/src/main/java/com/youlai/boot/system/model/entity/RoleMenu.java index 38109693..e16825d6 100644 --- a/src/main/java/com/youlai/boot/system/model/entity/RoleMenu.java +++ b/src/main/java/com/youlai/boot/system/model/entity/RoleMenu.java @@ -2,7 +2,6 @@ package com.youlai.boot.system.model.entity; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; -import com.fasterxml.jackson.annotation.JsonInclude; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; diff --git a/src/main/java/com/youlai/boot/system/model/entity/SysUser.java b/src/main/java/com/youlai/boot/system/model/entity/SysUser.java index 916042d6..c1457e7d 100644 --- a/src/main/java/com/youlai/boot/system/model/entity/SysUser.java +++ b/src/main/java/com/youlai/boot/system/model/entity/SysUser.java @@ -1,7 +1,7 @@ package com.youlai.boot.system.model.entity; -import com.baomidou.mybatisplus.annotation.TableName; -import com.youlai.boot.common.base.BaseEntity; +import com.baomidou.mybatisplus.annotation.*; +import com.youlai.boot.framework.base.BaseEntity; import lombok.Data; import lombok.EqualsAndHashCode; @@ -41,11 +41,13 @@ public class SysUser extends BaseEntity { /** * 用户头像 */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String avatar; /** * 联系方式 */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String mobile; /** @@ -56,16 +58,19 @@ public class SysUser extends BaseEntity { /** * 用户邮箱 */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String email; /** * 创建人 ID */ + @TableField(fill = FieldFill.INSERT) private Long createBy; /** * 更新人 ID */ + @TableField(fill = FieldFill.INSERT_UPDATE) private Long updateBy; /** diff --git a/src/main/java/com/youlai/boot/system/model/entity/UserNotice.java b/src/main/java/com/youlai/boot/system/model/entity/UserNotice.java index e3032595..d6ebbd73 100644 --- a/src/main/java/com/youlai/boot/system/model/entity/UserNotice.java +++ b/src/main/java/com/youlai/boot/system/model/entity/UserNotice.java @@ -3,8 +3,7 @@ package com.youlai.boot.system.model.entity; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableLogic; import com.baomidou.mybatisplus.annotation.TableName; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.youlai.boot.common.base.BaseEntity; +import com.youlai.boot.framework.base.BaseEntity; import lombok.Data; import lombok.EqualsAndHashCode; diff --git a/src/main/java/com/youlai/boot/system/model/entity/UserSocial.java b/src/main/java/com/youlai/boot/system/model/entity/UserSocial.java index f8ddff8c..c88b2717 100644 --- a/src/main/java/com/youlai/boot/system/model/entity/UserSocial.java +++ b/src/main/java/com/youlai/boot/system/model/entity/UserSocial.java @@ -1,8 +1,8 @@ package com.youlai.boot.system.model.entity; import com.baomidou.mybatisplus.annotation.TableName; -import com.youlai.boot.common.base.BaseEntity; -import com.youlai.boot.system.enums.SocialPlatformEnum; +import com.youlai.boot.framework.base.BaseEntity; +import com.youlai.boot.common.enums.SocialPlatformEnum; import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/com/youlai/boot/system/model/vo/DeptVO.java b/src/main/java/com/youlai/boot/system/model/vo/DeptVO.java index 6cded325..7da69882 100644 --- a/src/main/java/com/youlai/boot/system/model/vo/DeptVO.java +++ b/src/main/java/com/youlai/boot/system/model/vo/DeptVO.java @@ -1,8 +1,8 @@ package com.youlai.boot.system.model.vo; -import com.fasterxml.jackson.annotation.JsonFormat; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import com.fasterxml.jackson.annotation.JsonFormat; import java.time.LocalDateTime; import java.util.List; diff --git a/src/main/java/com/youlai/boot/system/model/vo/LogPageVO.java b/src/main/java/com/youlai/boot/system/model/vo/LogPageVO.java index cd8fd342..5d9670ab 100644 --- a/src/main/java/com/youlai/boot/system/model/vo/LogPageVO.java +++ b/src/main/java/com/youlai/boot/system/model/vo/LogPageVO.java @@ -1,6 +1,5 @@ package com.youlai.boot.system.model.vo; -import com.fasterxml.jackson.annotation.JsonFormat; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; import io.swagger.v3.oas.annotations.media.Schema; @@ -70,7 +69,6 @@ public class LogPageVO implements Serializable { @Schema(description = "错误信息") private String errorMsg; - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTime; } diff --git a/src/main/java/com/youlai/boot/system/model/vo/RolePageVO.java b/src/main/java/com/youlai/boot/system/model/vo/RolePageVO.java index 92a975bb..27b96916 100644 --- a/src/main/java/com/youlai/boot/system/model/vo/RolePageVO.java +++ b/src/main/java/com/youlai/boot/system/model/vo/RolePageVO.java @@ -1,6 +1,5 @@ package com.youlai.boot.system.model.vo; -import com.fasterxml.jackson.annotation.JsonFormat; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -31,9 +30,7 @@ public class RolePageVO { @Schema(description="数据权限名称") private String dataScopeLabel; - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTime; - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime updateTime; } \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/system/model/vo/UserPageVO.java b/src/main/java/com/youlai/boot/system/model/vo/UserPageVO.java index d911182e..a74d1118 100644 --- a/src/main/java/com/youlai/boot/system/model/vo/UserPageVO.java +++ b/src/main/java/com/youlai/boot/system/model/vo/UserPageVO.java @@ -1,8 +1,8 @@ package com.youlai.boot.system.model.vo; -import com.fasterxml.jackson.annotation.JsonFormat; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import com.fasterxml.jackson.annotation.JsonFormat; import java.time.LocalDateTime; diff --git a/src/main/java/com/youlai/boot/system/model/vo/UserProfileVO.java b/src/main/java/com/youlai/boot/system/model/vo/UserProfileVO.java index 39ebd9a7..5caa4dda 100644 --- a/src/main/java/com/youlai/boot/system/model/vo/UserProfileVO.java +++ b/src/main/java/com/youlai/boot/system/model/vo/UserProfileVO.java @@ -1,8 +1,8 @@ package com.youlai.boot.system.model.vo; -import com.fasterxml.jackson.annotation.JsonFormat; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import com.fasterxml.jackson.annotation.JsonFormat; import java.util.Date; diff --git a/src/main/java/com/youlai/boot/system/security/adapter/PermissionAdapter.java b/src/main/java/com/youlai/boot/system/security/adapter/PermissionAdapter.java new file mode 100644 index 00000000..71cc6422 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/security/adapter/PermissionAdapter.java @@ -0,0 +1,27 @@ +package com.youlai.boot.system.security.adapter; + +import com.youlai.boot.framework.security.port.PermissionPort; +import com.youlai.boot.system.service.RoleMenuService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Set; + +/** + * 权限查询适配器。 + *

+ * 实现 framework 层的 {@link PermissionPort},委托 {@link RoleMenuService} 查询权限集合。 + * + * @see PermissionPort + */ +@Component +@RequiredArgsConstructor +public class PermissionAdapter implements PermissionPort { + + private final RoleMenuService roleMenuService; + + @Override + public Set getRolePerms(Set roleCodes) { + return roleMenuService.getRolePermsByRoleCodes(roleCodes); + } +} diff --git a/src/main/java/com/youlai/boot/system/security/adapter/UserAuthenticationAdapter.java b/src/main/java/com/youlai/boot/system/security/adapter/UserAuthenticationAdapter.java new file mode 100644 index 00000000..e12535e9 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/security/adapter/UserAuthenticationAdapter.java @@ -0,0 +1,39 @@ +package com.youlai.boot.system.security.adapter; + +import com.youlai.boot.common.enums.SocialPlatformEnum; +import com.youlai.boot.framework.security.model.SecurityUser; +import com.youlai.boot.framework.security.port.UserAuthenticationPort; +import com.youlai.boot.system.service.UserService; +import com.youlai.boot.system.service.UserSocialService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 用户认证信息查询适配器。 + *

+ * 实现 framework 层的 {@link UserAuthenticationPort},委托 system 层服务完成查询。 + * + * @see UserAuthenticationPort + */ +@Component +@RequiredArgsConstructor +public class UserAuthenticationAdapter implements UserAuthenticationPort { + + private final UserService userService; + private final UserSocialService userSocialService; + + @Override + public SecurityUser getAuthInfoByUsername(String username) { + return userService.getAuthInfoByUsername(username); + } + + @Override + public SecurityUser getAuthInfoByMobile(String mobile) { + return userService.getAuthInfoByMobile(mobile); + } + + @Override + public SecurityUser getAuthInfoByOpenid(SocialPlatformEnum platform, String openid) { + return userSocialService.getAuthInfoByOpenid(platform, openid); + } +} diff --git a/src/main/java/com/youlai/boot/system/service/UserService.java b/src/main/java/com/youlai/boot/system/service/UserService.java index 48c5f58f..0505cf3f 100644 --- a/src/main/java/com/youlai/boot/system/service/UserService.java +++ b/src/main/java/com/youlai/boot/system/service/UserService.java @@ -3,7 +3,7 @@ package com.youlai.boot.system.service; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.service.IService; import com.youlai.boot.common.model.Option; -import com.youlai.boot.framework.security.model.UserAuthInfo; +import com.youlai.boot.framework.security.model.SecurityUser; import com.youlai.boot.system.model.vo.CurrentUserVO; import com.youlai.boot.system.model.vo.UserExportVO; import com.youlai.boot.system.model.entity.SysUser; @@ -78,14 +78,9 @@ public interface UserService extends IService { * 根据用户名获取认证信息 * * @param username 用户名 - * @return {@link UserAuthInfo} + * @return {@link SecurityUser} */ - UserAuthInfo getAuthInfoByUsername(String username); - - default UserAuthInfo getAuthCredentialsByUsername(String username) { - return getAuthInfoByUsername(username); - } - + SecurityUser getAuthInfoByUsername(String username); /** * 获取导出用户列表 @@ -194,12 +189,8 @@ public interface UserService extends IService { * 根据手机号获取用户认证信息 * * @param mobile 手机号 - * @return {@link UserAuthInfo} + * @return {@link SecurityUser} */ - UserAuthInfo getAuthInfoByMobile(String mobile); - - default UserAuthInfo getAuthCredentialsByMobile(String mobile) { - return getAuthInfoByMobile(mobile); - } + SecurityUser getAuthInfoByMobile(String mobile); } diff --git a/src/main/java/com/youlai/boot/system/service/UserSocialService.java b/src/main/java/com/youlai/boot/system/service/UserSocialService.java index 943792dc..6d088626 100644 --- a/src/main/java/com/youlai/boot/system/service/UserSocialService.java +++ b/src/main/java/com/youlai/boot/system/service/UserSocialService.java @@ -1,8 +1,8 @@ package com.youlai.boot.system.service; import com.baomidou.mybatisplus.extension.service.IService; -import com.youlai.boot.framework.security.model.UserAuthInfo; -import com.youlai.boot.system.enums.SocialPlatformEnum; +import com.youlai.boot.framework.security.model.SecurityUser; +import com.youlai.boot.common.enums.SocialPlatformEnum; import com.youlai.boot.system.model.entity.UserSocial; /** @@ -57,7 +57,15 @@ public interface UserSocialService extends IService { * @param openid openid * @return 用户认证信息 */ - UserAuthInfo getAuthInfoByOpenid(SocialPlatformEnum platform, String openid); + SecurityUser getAuthInfoByOpenid(SocialPlatformEnum platform, String openid); + + /** + * 根据用户ID获取用户认证信息 + * + * @param userId 用户ID + * @return 用户认证信息 + */ + SecurityUser getAuthInfoByUserId(Long userId); /** * 更新session_key diff --git a/src/main/java/com/youlai/boot/system/service/impl/ConfigServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/ConfigServiceImpl.java index 21962565..1702bfc7 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/ConfigServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/ConfigServiceImpl.java @@ -12,7 +12,6 @@ import com.youlai.boot.system.model.form.ConfigForm; import com.youlai.boot.system.model.query.ConfigQuery; import com.youlai.boot.system.model.vo.ConfigVO; import com.youlai.boot.system.service.ConfigService; -import com.youlai.boot.framework.security.util.SecurityUtils; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; @@ -78,7 +77,6 @@ public class ConfigServiceImpl extends ServiceImpl impleme super.count(new LambdaQueryWrapper().eq(Config::getConfigKey, configForm.getConfigKey())) == 0, "配置键已存在"); Config config = configConverter.toEntity(configForm); - config.setCreateBy(SecurityUtils.getUserId()); return this.save(config); } @@ -107,7 +105,6 @@ public class ConfigServiceImpl extends ServiceImpl impleme super.count(new LambdaQueryWrapper().eq(Config::getConfigKey, configForm.getConfigKey()).ne(Config::getId, id)) == 0, "配置键已存在"); Config config = configConverter.toEntity(configForm); - config.setUpdateBy(SecurityUtils.getUserId()); return this.updateById(config); } diff --git a/src/main/java/com/youlai/boot/system/service/impl/DeptServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/DeptServiceImpl.java index b2495bfe..c003d5ed 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/DeptServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/DeptServiceImpl.java @@ -84,7 +84,7 @@ public class DeptServiceImpl extends ServiceImpl implements De * @param deptList 部门列表 * @return 部门树形列表 */ - public List recurDeptList(Long parentId, List deptList) { + private List recurDeptList(Long parentId, List deptList) { return deptList.stream() .filter(dept -> dept.getParentId().equals(parentId)) .map(dept -> { @@ -150,7 +150,6 @@ public class DeptServiceImpl extends ServiceImpl implements De String treePath = generateDeptTreePath(formData.getParentId()); entity.setTreePath(treePath); - entity.setCreateBy(SecurityUtils.getUserId()); // 保存部门并返回部门ID boolean result = this.save(entity); Assert.isTrue(result, "部门保存失败"); @@ -236,7 +235,7 @@ public class DeptServiceImpl extends ServiceImpl implements De * @param deptList 部门列表 * @return 部门表格层级列表 */ - public static List> recurDeptTreeOptions(long parentId, List deptList) { + private static List> recurDeptTreeOptions(long parentId, List deptList) { return CollectionUtil.emptyIfNull(deptList).stream() .filter(dept -> dept.getParentId().equals(parentId)) .map(dept -> { diff --git a/src/main/java/com/youlai/boot/system/service/impl/NoticeServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/NoticeServiceImpl.java index 5052c03c..d4e23147 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/NoticeServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/NoticeServiceImpl.java @@ -95,7 +95,6 @@ public class NoticeServiceImpl extends ServiceImpl impleme } } Notice entity = noticeConverter.toEntity(formData); - entity.setCreateBy(SecurityUtils.getUserId()); return this.save(entity); } @@ -250,7 +249,6 @@ public class NoticeServiceImpl extends ServiceImpl impleme notice.setPublishStatus(NoticePublishStatusEnum.REVOKED.getValue()); notice.setRevokeTime(LocalDateTime.now()); - notice.setUpdateBy(SecurityUtils.getUserId()); boolean revokeResult = this.updateById(notice); diff --git a/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java index 8ffef8d4..84b80775 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java @@ -16,7 +16,7 @@ import com.youlai.boot.framework.integration.mail.service.MailService; import com.youlai.boot.framework.integration.sms.enums.SmsTypeEnum; import com.youlai.boot.framework.integration.sms.service.SmsService; import com.youlai.boot.framework.security.model.RoleDataScope; -import com.youlai.boot.framework.security.model.UserAuthInfo; +import com.youlai.boot.framework.security.model.SecurityUser; import com.youlai.boot.framework.security.token.TokenManager; import com.youlai.boot.framework.security.util.SecurityUtils; import com.youlai.boot.system.converter.UserConverter; @@ -133,7 +133,6 @@ public class UserServiceImpl extends ServiceImpl implements // 设置默认加密密码 String defaultEncryptPwd = passwordEncoder.encode(SystemConstants.DEFAULT_PASSWORD); entity.setPassword(defaultEncryptPwd); - entity.setCreateBy(SecurityUtils.getUserId()); // 新增用户 boolean result = this.save(entity); @@ -171,7 +170,6 @@ public class UserServiceImpl extends ServiceImpl implements // form -> entity SysUser entity = userConverter.toEntity(userForm); - entity.setUpdateBy(SecurityUtils.getUserId()); // 修改用户 boolean result = this.updateById(entity); @@ -206,39 +204,34 @@ public class UserServiceImpl extends ServiceImpl implements * 根据用户名获取认证凭证信息 * * @param username 用户名 - * @return 用户认证凭证信息 {@link UserAuthInfo} + * @return 用户认证凭证信息 {@link SecurityUser} */ @Override - public UserAuthInfo getAuthInfoByUsername(String username) { - UserAuthInfo userAuthInfo = this.baseMapper.getAuthInfoByUsername(username); - if (userAuthInfo != null) { - Set roles = userAuthInfo.getRoles(); - // 获取数据权限列表(用于并集策略) + public SecurityUser getAuthInfoByUsername(String username) { + SecurityUser securityUser = this.baseMapper.getAuthInfoByUsername(username); + if (securityUser != null) { + Set roles = securityUser.getRoles(); List dataScopes = roleService.getRoleDataScopes(roles); - userAuthInfo.setDataScopes(dataScopes); + securityUser.setDataScopes(dataScopes); } - return userAuthInfo; + return securityUser; } /** * 根据手机号获取用户认证信息 - * - * @param mobile 手机号 - * @return 用户认证信息 */ @Override - public UserAuthInfo getAuthInfoByMobile(String mobile) { + public SecurityUser getAuthInfoByMobile(String mobile) { if (StrUtil.isBlank(mobile)) { return null; } - UserAuthInfo userAuthInfo = this.baseMapper.getAuthInfoByMobile(mobile); - if (userAuthInfo != null) { - Set roles = userAuthInfo.getRoles(); - // 获取数据权限列表(用于并集策略) + SecurityUser securityUser = this.baseMapper.getAuthInfoByMobile(mobile); + if (securityUser != null) { + Set roles = securityUser.getRoles(); List dataScopes = roleService.getRoleDataScopes(roles); - userAuthInfo.setDataScopes(dataScopes); + securityUser.setDataScopes(dataScopes); } - return userAuthInfo; + return securityUser; } /** diff --git a/src/main/java/com/youlai/boot/system/service/impl/UserSocialServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/UserSocialServiceImpl.java index f9a37c59..d0891975 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/UserSocialServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/UserSocialServiceImpl.java @@ -3,8 +3,8 @@ package com.youlai.boot.system.service.impl; import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; -import com.youlai.boot.framework.security.model.UserAuthInfo; -import com.youlai.boot.system.enums.SocialPlatformEnum; +import com.youlai.boot.framework.security.model.SecurityUser; +import com.youlai.boot.common.enums.SocialPlatformEnum; import com.youlai.boot.system.mapper.UserSocialMapper; import com.youlai.boot.system.model.entity.UserSocial; import com.youlai.boot.system.service.UserSocialService; @@ -86,7 +86,7 @@ public class UserSocialServiceImpl extends ServiceImpl", + "description": "允许的文件扩展名白名单(置空表示不限制)" + }, + { + "name": "file-storage.minio.endpoint", "type": "java.lang.String", "description": "MinIO 服务 Endpoint" }, { - "name": "oss.minio.access-key", + "name": "file-storage.minio.access-key", "type": "java.lang.String", "description": "MinIO 访问凭据" }, { - "name": "oss.minio.secret-key", + "name": "file-storage.minio.secret-key", "type": "java.lang.String", "description": "MinIO 凭据密钥" }, { - "name": "oss.minio.bucket-name", + "name": "file-storage.minio.bucket", "type": "java.lang.String", "description": "MinIO 存储桶名称" }, { - "name": "oss.minio.custom-domain", + "name": "file-storage.minio.domain", "type": "java.lang.String", - "description": "MinIO 自定义域名" + "description": "MinIO 自定义域名(文件 URL 域名)" }, { - "name": "oss.aliyun.endpoint", + "name": "file-storage.aliyun.endpoint", "type": "java.lang.String", "description": "阿里云 OSS 服务 Endpoint" }, { - "name": "oss.aliyun.access-key-id", + "name": "file-storage.aliyun.access-key-id", "type": "java.lang.String", "description": "阿里云 OSS 访问凭据 ID" }, { - "name": "oss.aliyun.access-key-secret", + "name": "file-storage.aliyun.access-key-secret", "type": "java.lang.String", "description": "阿里云 OSS 凭据密钥" }, { - "name": "oss.aliyun.bucket-name", + "name": "file-storage.aliyun.bucket", "type": "java.lang.String", "description": "阿里云 OSS 存储桶名称" }, + { + "name": "file-storage.local.path", + "type": "java.lang.String", + "description": "本地存储根目录(相对路径基于工作目录)" + }, { "name": "xxl.job.enabled", "type": "java.lang.Boolean", diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index b1346bbe..84b2b9d0 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -15,7 +15,7 @@ spring: data: redis: - database: 12 + database: 0 host: 175.178.213.60 port: 26379 password: fanhuitong @@ -83,9 +83,11 @@ mybatis-plus: # 接口限流 rate-limit: default-limit: 60 - default-window: 60 + default-window: 60s ip: - enabled: false # 开发环境关闭 IP 全局限流 + enabled: true # 开发环境开启 IP 全局限流(供限流测试) + limit: 1000 + window: 60s # 安全配置 security: @@ -105,6 +107,9 @@ security: - /api/v1/auth/refresh-token # 刷新令牌接口 - /api/v1/wxma/auth/** # 微信小程序认证接口(静默登录/手机号快捷登录/绑定手机号) - /api/v1/logs/** # 日志接口(访问日志列表) + - /api/v1/auth/qr-code/generate # 扫码登录-生成票据 + - /api/v1/auth/qr-code/status # 扫码登录-查询状态 + - /api/v1/auth/qr-code/login # 扫码登录-换取会话令牌 - /api/v1/sn/** # 移动设备专用接口(通过设备签名验证) # 非安全端点路径,完全绕过 Spring Security 的过滤器 unsecured-urls: @@ -116,36 +121,39 @@ security: - /favicon.ico - /error -# 文件存储配置 -oss: - # OSS 类型 (目前支持aliyun、minio) +# 文件存储 +file-storage: + # 存储类型:minio | aliyun | local type: minio - # MinIO 对象存储服务 + # 上传限制 + upload: + # 单文件大小上限(同时作为 spring.servlet.multipart 的上限,单一来源) + max-file-size: 50MB + # 允许的文件扩展名白名单(置空表示不限制) + allowed-extensions: + - jpg + - jpeg + - png + - gif + # MinIO 对象存储 minio: - # 服务Endpoint - endpoint: http://localhost:9000 - # 访问凭据 - access-key: minioadmin - # 凭据密钥 - secret-key: minioadmin - # 存储桶名称 - bucket-name: public - # (可选)自定义域名,如果配置了域名,生成的文件URL是域名格式,未配置则URL则是IP格式 (eg: https://www.youlai.tech/storage) - custom-domain: - # 阿里云OSS对象存储服务 + endpoint: http://111.229.83.153:9000 + access-key: bybaddp7zyARpgNbEGKf + secret-key: p9rBdQZPBIJcMH23iyFkZkXmmawbmwPlk3JLlaaj + bucket: public + # 自定义域名:配置后文件 URL 走域名,留空则用 endpoint(IP)格式 + domain: + # 阿里云 OSS aliyun: - # 服务Endpoint endpoint: oss-cn-hangzhou.aliyuncs.com - # 访问凭据 access-key-id: your-access-key-id - # 凭据密钥 access-key-secret: your-access-key-secret - # 存储桶名称 - bucket-name: default + bucket: default # 本地存储 local: - # 文件存储路径 请注意下,mac用户请使用 /Users/your-username/your-path/,否则会有权限问题,windows用户请使用 D:/your-path/ - storage-path: /Users/theo/home/ + # 存储根目录(相对路径基于工作目录;生产建议挂载独立卷) + path: ./uploads/ + # 短信配置 sms: # 阿里云短信 diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 643e2bd6..19e1c4a7 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -10,7 +10,7 @@ spring: password: fanhuitong data: redis: - database: 11 + database: 1 host: 175.178.213.60 port: 26379 password: fanhuitong @@ -77,11 +77,11 @@ mybatis-plus: # 接口限流 rate-limit: default-limit: 10 - default-window: 1 + default-window: 60s ip: enabled: true limit: 1000 # IP 全局:1000 req / 60s - window-seconds: 60 + window: 60s # 安全配置 security: @@ -101,6 +101,9 @@ security: - /api/v1/auth/refresh-token # 刷新令牌接口 - /api/v1/wxma/auth/** # 微信小程序认证接口(静默登录/手机号快捷登录/绑定手机号) - /api/v1/logs/** # 日志接口(访问日志列表) + - /api/v1/auth/qr-code/generate # 扫码登录-生成票据 + - /api/v1/auth/qr-code/status # 扫码登录-查询状态 + - /api/v1/auth/qr-code/login # 扫码登录-换取会话令牌 - /api/v1/sn/** # 移动设备专用接口(通过设备签名验证) # 非安全端点路径,完全绕过 Spring Security 的过滤器 unsecured-urls: @@ -112,36 +115,38 @@ security: - /favicon.ico - /error -# 文件存储配置 -oss: - # OSS 类型 (目前支持aliyun、minio) +# 文件存储 +file-storage: + # 存储类型:minio | aliyun | local type: minio - # MinIO 对象存储服务 + # 上传限制 + upload: + # 单文件大小上限(同时作为 spring.servlet.multipart 的上限,单一来源) + max-file-size: 50MB + # 允许的文件扩展名白名单(置空表示不限制) + allowed-extensions: + - jpg + - jpeg + - png + - gif + # MinIO 对象存储 minio: - # 服务Endpoint endpoint: http://localhost:9000 - # 访问凭据 access-key: minioadmin - # 凭据密钥 secret-key: minioadmin - # 存储桶名称 - bucket-name: public - # (可选)自定义域名,如果配置了域名,生成的文件URL是域名格式,未配置则URL则是IP格式 (eg: https://www.youlai.tech/storage) - custom-domain: - # 阿里云OSS对象存储服务 + bucket: public + # 自定义域名:配置后文件 URL 走域名,留空则用 endpoint(IP)格式 + domain: + # 阿里云 OSS aliyun: - # 服务Endpoint endpoint: oss-cn-hangzhou.aliyuncs.com - # 访问凭据 access-key-id: your-access-key-id - # 凭据密钥 access-key-secret: your-access-key-secret - # 存储桶名称 - bucket-name: default + bucket: default # 本地存储 local: - # 文件存储路径 请注意下,mac用户请使用 /Users/your-username/your-path/,否则会有权限问题,windows用户请使用 D:/your-path/ - storage-path: /Users/theo/home/ + # 存储根目录(相对路径基于工作目录;生产建议挂载独立卷) + path: ./uploads/ # 短信配置 sms: # 阿里云短信 @@ -173,8 +178,9 @@ springdoc: packages-to-scan: # 扫描的 Controller 包,限制只生成指定包的接口文档 - com.youlai.boot.auth.controller - com.youlai.boot.system.controller - - com.youlai.boot.module.file.controller - - com.youlai.boot.module.codegen.controller + - com.youlai.boot.file.controller + - com.youlai.boot.codegen.controller + - com.youlai.boot.message.controller default-flat-param-object: true # 将对象参数扁平化显示在文档中 # knife4j 配置 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d1ce1eb8..d0e236f3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -7,8 +7,9 @@ spring: import: classpath:codegen.yml servlet: multipart: - max-file-size: 50MB - max-request-size: 50MB + # 单文件大小上限,取自 file-storage.upload.max-file-size(单一来源) + max-file-size: ${file-storage.upload.max-file-size:50MB} + max-request-size: ${file-storage.upload.max-file-size:50MB} # 在 banner.txt 中显示项目版本,使用 @project.version@ 从 pom.xml 获取 project: version: @project.version@ diff --git a/src/main/resources/mapper/codegen/GenTableColumnMapper.xml b/src/main/resources/mapper/codegen/GenTableColumnMapper.xml index 8cb86c3d..6a09a381 100644 --- a/src/main/resources/mapper/codegen/GenTableColumnMapper.xml +++ b/src/main/resources/mapper/codegen/GenTableColumnMapper.xml @@ -2,6 +2,6 @@ - + diff --git a/src/main/resources/mapper/codegen/GenTableMapper.xml b/src/main/resources/mapper/codegen/GenTableMapper.xml index 0271fa20..74e7ca3e 100644 --- a/src/main/resources/mapper/codegen/GenTableMapper.xml +++ b/src/main/resources/mapper/codegen/GenTableMapper.xml @@ -2,6 +2,6 @@ - + diff --git a/src/main/resources/mapper/system/ConfigMapper.xml b/src/main/resources/mapper/system/ConfigMapper.xml deleted file mode 100644 index f291b895..00000000 --- a/src/main/resources/mapper/system/ConfigMapper.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/src/main/resources/mapper/system/DeptMapper.xml b/src/main/resources/mapper/system/DeptMapper.xml deleted file mode 100644 index fadf8a06..00000000 --- a/src/main/resources/mapper/system/DeptMapper.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/src/main/resources/mapper/system/UserMapper.xml b/src/main/resources/mapper/system/UserMapper.xml index b70f0ae6..c6ddbe80 100644 --- a/src/main/resources/mapper/system/UserMapper.xml +++ b/src/main/resources/mapper/system/UserMapper.xml @@ -118,7 +118,7 @@ - + diff --git a/src/main/resources/mapper/system/UserSocialMapper.xml b/src/main/resources/mapper/system/UserSocialMapper.xml index abd7bd64..5856c87d 100644 --- a/src/main/resources/mapper/system/UserSocialMapper.xml +++ b/src/main/resources/mapper/system/UserSocialMapper.xml @@ -5,13 +5,14 @@ - + + @@ -26,6 +27,7 @@ u.dept_id, u.password, u.status, + u.avatar, r.code FROM sys_user u diff --git a/src/main/resources/templates/codegen/backend/entity.java.vm b/src/main/resources/templates/codegen/backend/entity.java.vm index 15a8dd43..7bb0d76e 100644 --- a/src/main/resources/templates/codegen/backend/entity.java.vm +++ b/src/main/resources/templates/codegen/backend/entity.java.vm @@ -9,7 +9,7 @@ import java.time.LocalDateTime; import java.math.BigDecimal; #end import com.baomidou.mybatisplus.annotation.TableName; -import ${packageName}.common.base.BaseEntity; +import ${packageName}.framework.base.BaseEntity; /** * $!{businessName}实体对象 diff --git a/src/test/java/com/youlai/boot/security/token/JwtTokenManagerTest.java b/src/test/java/com/youlai/boot/security/token/JwtTokenManagerTest.java index afd1e13d..14979f0d 100644 --- a/src/test/java/com/youlai/boot/security/token/JwtTokenManagerTest.java +++ b/src/test/java/com/youlai/boot/security/token/JwtTokenManagerTest.java @@ -3,7 +3,7 @@ package com.youlai.boot.security.token; import com.youlai.boot.framework.security.config.SecurityProperties; import com.youlai.boot.framework.security.model.AuthenticationToken; import com.youlai.boot.framework.security.model.RoleDataScope; -import com.youlai.boot.framework.security.model.SysUserDetails; +import com.youlai.boot.framework.security.model.SecurityUserDetails; import com.youlai.boot.framework.security.token.JwtTokenManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -16,7 +16,6 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import java.util.Collection; import java.util.List; @@ -92,7 +91,7 @@ class JwtTokenManagerTest { assertThat(parsed).isNotNull(); assertThat(parsed.getName()).isEqualTo("testuser"); - SysUserDetails userDetails = (SysUserDetails) parsed.getPrincipal(); + SecurityUserDetails userDetails = (SecurityUserDetails) parsed.getPrincipal(); assertThat(userDetails.getUserId()).isEqualTo(1L); assertThat(userDetails.getDeptId()).isEqualTo(100L); } @@ -120,7 +119,7 @@ class JwtTokenManagerTest { AuthenticationToken token = tokenManager.generateToken(authentication); Authentication parsed = tokenManager.parseToken(token.getAccessToken()); - SysUserDetails userDetails = (SysUserDetails) parsed.getPrincipal(); + SecurityUserDetails userDetails = (SecurityUserDetails) parsed.getPrincipal(); List dataScopes = userDetails.getDataScopes(); assertThat(dataScopes).hasSize(2); @@ -270,9 +269,9 @@ class JwtTokenManagerTest { Authentication parsed = tokenManager.parseToken(token.getAccessToken()); assertThat(parsed).isInstanceOf(UsernamePasswordAuthenticationToken.class); - assertThat(parsed.getPrincipal()).isInstanceOf(SysUserDetails.class); + assertThat(parsed.getPrincipal()).isInstanceOf(SecurityUserDetails.class); - SysUserDetails userDetails = (SysUserDetails) parsed.getPrincipal(); + SecurityUserDetails userDetails = (SecurityUserDetails) parsed.getPrincipal(); assertThat(userDetails.getUserId()).isEqualTo(1L); assertThat(userDetails.getDeptId()).isEqualTo(100L); assertThat(userDetails.getUsername()).isEqualTo("testuser"); @@ -285,7 +284,7 @@ class JwtTokenManagerTest { AuthenticationToken token = tokenManager.generateToken(authentication); Authentication parsed = tokenManager.parseToken(token.getAccessToken()); - SysUserDetails userDetails = (SysUserDetails) parsed.getPrincipal(); + SecurityUserDetails userDetails = (SecurityUserDetails) parsed.getPrincipal(); List dataScopes = userDetails.getDataScopes(); assertThat(dataScopes).hasSize(1); @@ -313,7 +312,7 @@ class JwtTokenManagerTest { } private Authentication createTestAuthentication() { - SysUserDetails userDetails = new SysUserDetails(); + SecurityUserDetails userDetails = new SecurityUserDetails(); userDetails.setUserId(1L); userDetails.setUsername("testuser"); userDetails.setDeptId(100L); @@ -322,10 +321,7 @@ class JwtTokenManagerTest { new RoleDataScope("ADMIN", 1, null), // 全部数据权限 new RoleDataScope("USER", 4, null) // 本人数据权限 )); - userDetails.setAuthorities(Set.of( - new SimpleGrantedAuthority("ROLE_ADMIN"), - new SimpleGrantedAuthority("ROLE_USER") - )); + userDetails.setRoles(Set.of("ADMIN", "USER")); return new UsernamePasswordAuthenticationToken( userDetails, @@ -335,7 +331,7 @@ class JwtTokenManagerTest { } private Authentication createTestAuthenticationWithCustomScope() { - SysUserDetails userDetails = new SysUserDetails(); + SecurityUserDetails userDetails = new SecurityUserDetails(); userDetails.setUserId(2L); userDetails.setUsername("customuser"); userDetails.setDeptId(200L); @@ -343,9 +339,7 @@ class JwtTokenManagerTest { userDetails.setDataScopes(List.of( new RoleDataScope("CUSTOM_ROLE", 5, List.of(10L, 20L, 30L)) // 自定义部门权限 )); - userDetails.setAuthorities(Set.of( - new SimpleGrantedAuthority("ROLE_CUSTOM") - )); + userDetails.setRoles(Set.of("CUSTOM")); return new UsernamePasswordAuthenticationToken( userDetails,

- 公众号「有来技术」
+ 公众号「有来技术」
公众号「有来技术」
     - 小程序「有来技术」
+ 小程序「有来技术」
小程序「有来技术」
     - 添加作者微信
+ 添加作者微信
添加作者微信