commit efed257f35dde7f2140b6519331e3ee0b30c0e15 Author: review512jwy@163.com <“review512jwy@163.com”> Date: Tue Apr 14 21:34:41 2026 +0800 初始化 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..fd230fd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,35 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +# Java files +[*.java] +indent_style = space +indent_size = 4 + +# XML files +[*.xml] +indent_style = space +indent_size = 4 + +# YAML files +[*.{yml,yaml}] +indent_style = space +indent_size = 2 + +# Properties files +[*.properties] +indent_style = space +indent_size = 4 + +# Markdown files +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a3fd9cc --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +# Auto detect text files and perform LF normalization +* text=auto eol=lf + +# Java files +*.java text eol=lf +*.xml text eol=lf + +# Config files +*.yml text eol=lf +*.yaml text eol=lf +*.properties text eol=lf + +# Shell scripts +*.sh text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18f34bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Created by .ignore support plugin (hsz.mobi) +### Example sysUserDetails template template +### Example sysUserDetails template + +# IntelliJ project files +.idea +*.iml +out +gen +target +*.log +logs +.history + + +docker/*/data/ +docker/minio/config +docker/xxljob/logs +application-youlai.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3ebb384 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# 基础镜像 +FROM openjdk:17 + +# 维护者信息 +LABEL maintainer="youlai " + +# 设置时区(Debian直接使用环境变量) +ENV TZ=Asia/Shanghai + +# 在运行时自动挂载 /tmp 目录为匿名卷 +VOLUME /tmp + +# 添加应用 +ADD target/youlai-boot.jar app.jar + +# 启动命令 +CMD java \ + -Xms512m -Xmx512m \ + -Djava.security.egd=file:/dev/./urandom \ + -jar /app.jar + +# 暴露端口 +EXPOSE 8000 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cb10d6a --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023-present 有来开源 + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7249adf --- /dev/null +++ b/README.md @@ -0,0 +1,195 @@ +

+ youlai-boot +

+ +

youlai-boot

+ +

+ Spring Boot 4 企业级权限管理系统后端 +

+ +

+ Documentation + Demo + +

+ +

+ + +

+ +--- + +> [English](#) | 简体中文 + +--- + +## 🎯 项目定位 + +一套 **Spring Boot 4 后端权限管理系统**,配套前端 [vue3-element-admin](https://gitee.com/youlaiorg/vue3-element-admin),并提供 **6 种语言实现**(Java / Node.js / Go / Python / PHP / C#),共享同一套 API 规范与数据库结构。 + +**适合场景**:企业中后台管理系统的后端学习参考、二次开发基础脚手架。 + +--- + +## ✨ 核心能力 + +| 能力 | 说明 | +|------|------| +| 🔐 **安全体系** | Spring Security + JWT + Redis 多端互斥、令牌续期、验证码防刷 | +| 🛡️ **细粒度权限** | RBAC 五级:数据权限 → 菜单 → 按钮 → 接口 → 字段级 | +| ⚡ **代码生成器** | 可视化配置表单,一键生成 Entity/VO/Controller/Service/CRUD 前后端代码 | +| 📦 **模块齐全** | 用户、角色、菜单、部门、字典、文件、定时任务、消息中心、操作日志 | +| 🌐 **多租户 SaaS** | 数据隔离 + 租户配置,独立 [youlai-boot-tenant](https://gitee.com/youlaiorg/youlai-boot-tenant) 版本 | +| 🔌 **实时通信** | 内置 SSE 推送服务(在线用户数、字典同步、通知广播) | +| 📱 **生态完整** | 配套移动端 [youlai-app](https://gitee.com/youlaiorg/youlai-app)(UniApp)+ 完整[技术文档](https://www.youlai.tech/docs/admin/) | + +## 🏗️ 技术栈 + +``` +┌─────────────────────────────────────────────┐ +│ youlai-boot │ +│ │ +│ ┌───────────┐ ┌──────────┐ ┌───────────┐ │ +│ │ Spring │ │ MyBatis │ │ Redis │ │ +│ │ Security │ │ Plus │ │ + JWT │ │ +│ └─────┬─────┘ └────┬─────┘ └─────┬─────┘ │ +│ │ │ │ │ +│ ┌─────▼──────────────▼─────────────▼───┐ │ +│ │ Spring Boot 4 │ │ +│ │ JDK 17 (LTS) │ │ +│ └───────────────────────────────────────┘ │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Swagger │ │ XXL-JOB │ │ MinIO │ │ +│ │ (接口文档) │ │(定时任务) │ │(文件存储) │ │ +│ └──────────┘ └──────────┘ └──────────┘ │ +│ │ +│ MySQL 8.0 · Redis 7 · Docker Compose │ +└─────────────────────────────────────────────┘ +``` + + +

+ + 系统截图 +

+

↑ 系统运行效果(待补充实际截图)

+ +--- + +## 🚀 快速开始 + +### 环境要求 + +| 组件 | 版本 | +|------|------| +| JDK | 17+ | +| MySQL | 8.0+ / 5.7+ | +| Redis | 6.0+ | + +### 本地启动 + +```bash +# 1. 克隆项目 +git clone https://gitee.com/youlaiorg/youlai-boot.git + +# 2. 导入数据库脚本 sql/mysql/youlai_admin.sql + +# 3. 修改 application-dev.yml 配置 MySQL 和 Redis 连接信息 +# 💡 默认已配置线上只读数据源,可直接启动体验 + +# 4. 运行 YouLaiBootApplication.java,访问 http://localhost:8000/doc.html +``` + +默认账号:`admin` / `123456` + +### Docker 部署 + +```bash +cd docker && docker-compose up -d +``` + +详细指南:[部署文档](https://www.youlai.tech/docs/admin/backends/java/deploy) · [开发规范](https://www.youlai.tech/docs/admin/backends/java/dev-standards) + +## 📁 目录结构 + +``` +youlai-boot/ +├── docker/ # Docker 部署编排 +├── sql/ # 数据库初始化脚本 +├── src/main/java/com/youlai/boot/ +│ ├── YouLaiBootApplication # 启动类 +│ ├── auth/ # 认证授权(登录/登出/令牌) +│ ├── codegen/ # 代码生成器 +│ ├── common/ # 公共模块(常量/枚举/统一响应) +│ ├── file/ # 文件服务(MinIO/本地存储) +│ ├── framework/ # 技术基座 +│ │ ├── apidoc/ # OpenAPI/Swagger +│ │ ├── cache/ # Redis/Caffeine 缓存 +│ │ ├── captcha/ # 图形验证码 +│ │ ├── integration/ # 短信/邮件/微信 +│ │ ├── job/ # XXL-Job 定时任务 +│ │ ├── mybatis/ # MyBatis Plus 配置 +│ │ ├── security/ # 安全过滤器/Token机制 +│ │ └── web/ # 全局异常/跨域/限流 +│ ├── message/ # SSE 消息推送 +│ └── system/ # 业务模块(用户/角色/菜单/部门) +└── pom.xml # Maven 依赖 +``` + +## 🌐 相关生态 + +| 项目 | 技术栈 | 定位 | +|------|--------|------| +| [**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-boot-tenant**](https://gitee.com/youlaiorg/youlai-boot-tenant) | Spring Boot 4 | **SaaS 多租户版本** | +| [**youlai-boot-flex**](https://gitee.com/youlaiorg/youlai-boot-flex) | Spring Boot 3 + MyBatis-Flex | MyBatis-Flex 版 | +| [**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# 后端** | + +> 六种后端共享同一套 **RESTful API 规范** 和 **数据库结构**,前端可无缝切换。 + +## 📘 文档资源 + +| 资源 | 地址 | +|------|------| +| **📖 完整文档站** | [docs.youlai.tech](https://www.youlai.tech/docs/admin/) | +| **🖥️ 在线预览(前端)** | [vue.youlai.tech](https://vue.youlai.tech) | +| **📱 在线预览(移动端)** | [app.youlai.tech](https://app.youlai.tech) | +| **🔗 接口文档** | 启动后访问 `/doc.html` | + +## 📊 项目统计 + +![Repobeats](https://repobeats.axiom.co/api/embed/544c5c0b5b3611a6c4d5ef0faa243a9066b89659.svg) + +## 🤝 参与贡献 + +欢迎 Issue、PR 和 Star!详见 [贡献指南](https://www.youlai.tech/docs/admin/faq/help)。 + +[![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/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..2144edd --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,69 @@ +# 创建一个名为 "youlai-boot" 的桥接网络,在同一个网络中的容器可以通过容器名互相访问 +networks: + youlai-boot: + driver: bridge + +services: + mysql: + image: mysql:8.0.29 + container_name: mysql + restart: unless-stopped # 重启策略:除非手动停止容器,否则自动重启 + environment: + - TZ=Asia/Shanghai + - LANG= en_US.UTF-8 + - MYSQL_ROOT_PASSWORD=123456 #设置 root 用户的密码 + volumes: + - ./mysql/conf/my.cnf:/etc/my.cnf # 挂载 my.cnf 文件到容器的指定路径 + - ./mysql/data:/var/lib/mysql # 持久化 MySQL 数据 + - ../sql/mysql:/docker-entrypoint-initdb.d # 初始化 SQL 脚本目录 + ports: + - 3306:3306 + networks: + - youlai-boot # 加入 "youlai-boot" 网络 + + redis: + image: redis:7.2.3 + container_name: redis + restart: unless-stopped + command: redis-server /etc/redis/redis.conf --requirepass 123456 --appendonly no # 启动 Redis 服务并添加密码为:123456,默认不开启 Redis AOF 方式持久化配置 + environment: + - TZ=Asia/Shanghai + volumes: + - ./redis/data:/data + - ./redis/config/redis.conf:/etc/redis/redis.conf + ports: + - 6379:6379 + networks: + - youlai-boot + + minio: + image: minio/minio:RELEASE.2024-07-16T23-46-41Z + container_name: minio + restart: unless-stopped + command: server /data --console-address ":9001" + ports: + - 9000:9000 + - 9001:9001 + environment: + - TZ=Asia/Shanghai + - LANG=en_US.UTF-8 + - MINIO_ROOT_USER=minioadmin + - MINIO_ROOT_PASSWORD=minioadmin + volumes: + - ./minio/data:/data + - ./minio/config:/root/.minio + networks: + - youlai-boot + + xxl-job-admin: + image: xuxueli/xxl-job-admin:2.4.0 + container_name: xxl-job-admin + restart: unless-stopped + environment: + PARAMS: '--spring.datasource.url=jdbc:mysql://mysql:3306/xxl_job?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai --spring.datasource.username=root --spring.datasource.password=123456 --spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver' + volumes: + - ./xxljob/logs:/data/applogs + ports: + - 8080:8080 + networks: + - youlai-boot diff --git a/docker/minio/README.md b/docker/minio/README.md new file mode 100644 index 0000000..e69de29 diff --git a/docker/mysql/conf/my.cnf b/docker/mysql/conf/my.cnf new file mode 100644 index 0000000..73981f8 --- /dev/null +++ b/docker/mysql/conf/my.cnf @@ -0,0 +1,20 @@ + + +[mysqld] +# 字符集与排序规则 +character-set-server = utf8mb4 # 服务端默认字符集 +collation-server = utf8mb4_0900_ai_ci # 服务端默认排序规则 + +# 网络与路径 +datadir = /var/lib/mysql # 数据文件存放的目录 +bind-address = 0.0.0.0 # 允许远程连接,默认 127.0.0.1 只允许本地连接 +port = 3306 # 显式指定端口(默认3306可不写) + +# 客户端字符集同步(避免乱码) +init_connect = 'SET NAMES utf8mb4' # 连接初始化时设置字符集 + +[client] +default-character-set = utf8mb4 # 客户端默认字符集 + +[mysql] +default-character-set = utf8mb4 # MySQL 命令行工具字符集 diff --git a/docker/redis/config/redis.conf b/docker/redis/config/redis.conf new file mode 100644 index 0000000..272c848 --- /dev/null +++ b/docker/redis/config/redis.conf @@ -0,0 +1,2297 @@ +# 下载地址: http://download.redis.io/redis-stable/redis.conf +# https://github.com/redis/redis/blob/7.2/redis.conf +# Redis configuration file example. +# +# Note that in order to read the configuration file, Redis must be +# started with the file path as first argument: +# +# ./redis-server /path/to/redis.conf + +# Note on units: when memory size is needed, it is possible to specify +# it in the usual form of 1k 5GB 4M and so forth: +# +# 1k => 1000 bytes +# 1kb => 1024 bytes +# 1m => 1000000 bytes +# 1mb => 1024*1024 bytes +# 1g => 1000000000 bytes +# 1gb => 1024*1024*1024 bytes +# +# units are case insensitive so 1GB 1Gb 1gB are all the same. + +################################## INCLUDES ################################### + +# Include one or more other config files here. This is useful if you +# have a standard template that goes to all Redis servers but also need +# to customize a few per-server settings. Include files can include +# other files, so use this wisely. +# +# Note that option "include" won't be rewritten by command "CONFIG REWRITE" +# from admin or Redis Sentinel. Since Redis always uses the last processed +# line as value of a configuration directive, you'd better put includes +# at the beginning of this file to avoid overwriting config change at runtime. +# +# If instead you are interested in using includes to override configuration +# options, it is better to use include as the last line. +# +# Included paths may contain wildcards. All files matching the wildcards will +# be included in alphabetical order. +# Note that if an include path contains a wildcards but no files match it when +# the server is started, the include statement will be ignored and no error will +# be emitted. It is safe, therefore, to include wildcard files from empty +# directories. +# +# include /path/to/local.conf +# include /path/to/other.conf +# include /path/to/fragments/*.conf +# + +################################## MODULES ##################################### + +# Load modules at startup. If the server is not able to load modules +# it will abort. It is possible to use multiple loadmodule directives. +# +# loadmodule /path/to/my_module.so +# loadmodule /path/to/other_module.so + +################################## NETWORK ##################################### + +# By default, if no "bind" configuration directive is specified, Redis listens +# for connections from all available network interfaces on the host machine. +# It is possible to listen to just one or multiple selected interfaces using +# the "bind" configuration directive, followed by one or more IP addresses. +# Each address can be prefixed by "-", which means that redis will not fail to +# start if the address is not available. Being not available only refers to +# addresses that does not correspond to any network interface. Addresses that +# are already in use will always fail, and unsupported protocols will always BE +# silently skipped. +# +# Examples: +# +# bind 192.168.1.100 10.0.0.1 # listens on two specific IPv4 addresses +# bind 127.0.0.1 ::1 # listens on loopback IPv4 and IPv6 +# bind * -::* # like the default, all available interfaces +# +# ~~~ WARNING ~~~ If the computer running Redis is directly exposed to the +# internet, binding to all the interfaces is dangerous and will expose the +# instance to everybody on the internet. So by default we uncomment the +# following bind directive, that will force Redis to listen only on the +# IPv4 and IPv6 (if available) loopback interface addresses (this means Redis +# will only be able to accept client connections from the same host that it is +# running on). +# +# IF YOU ARE SURE YOU WANT YOUR INSTANCE TO LISTEN TO ALL THE INTERFACES +# COMMENT OUT THE FOLLOWING LINE. +# +# You will also need to set a password unless you explicitly disable protected +# mode. +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +#bind 127.0.0.1 -::1 + +# By default, outgoing connections (from replica to master, from Sentinel to +# instances, cluster bus, etc.) are not bound to a specific local address. In +# most cases, this means the operating system will handle that based on routing +# and the interface through which the connection goes out. +# +# Using bind-source-addr it is possible to configure a specific address to bind +# to, which may also affect how the connection gets routed. +# +# Example: +# +# bind-source-addr 10.0.0.1 + +# Protected mode is a layer of security protection, in order to avoid that +# Redis instances left open on the internet are accessed and exploited. +# +# When protected mode is on and the default user has no password, the server +# only accepts local connections from the IPv4 address (127.0.0.1), IPv6 address +# (::1) or Unix domain sockets. +# +# By default protected mode is enabled. You should disable it only if +# you are sure you want clients from other hosts to connect to Redis +# even if no authentication is configured. +protected-mode no + +# Redis uses default hardened security configuration directives to reduce the +# attack surface on innocent users. Therefore, several sensitive configuration +# directives are immutable, and some potentially-dangerous commands are blocked. +# +# Configuration directives that control files that Redis writes to (e.g., 'dir' +# and 'dbfilename') and that aren't usually modified during runtime +# are protected by making them immutable. +# +# Commands that can increase the attack surface of Redis and that aren't usually +# called by users are blocked by default. +# +# These can be exposed to either all connections or just local ones by setting +# each of the configs listed below to either of these values: +# +# no - Block for any connection (remain immutable) +# yes - Allow for any connection (no protection) +# local - Allow only for local connections. Ones originating from the +# IPv4 address (127.0.0.1), IPv6 address (::1) or Unix domain sockets. +# +# enable-protected-configs no +# enable-debug-command no +# enable-business-command no + +# Accept connections on the specified port, default is 6379 (IANA #815344). +# If port 0 is specified Redis will not listen on a TCP socket. +port 6379 + +# TCP listen() backlog. +# +# In high requests-per-second environments you need a high backlog in order +# to avoid slow clients connection issues. Note that the Linux kernel +# will silently truncate it to the value of /proc/sys/net/core/somaxconn so +# make sure to raise both the value of somaxconn and tcp_max_syn_backlog +# in order to get the desired effect. +tcp-backlog 511 + +# Unix socket. +# +# Specify the path for the Unix socket that will be used to listen for +# incoming connections. There is no default, so Redis will not listen +# on a unix socket when not specified. +# +# unixsocket /run/redis.sock +# unixsocketperm 700 + +# Close the connection after a client is idle for N seconds (0 to disable) +timeout 0 + +# TCP keepalive. +# +# If non-zero, use SO_KEEPALIVE to send TCP ACKs to clients in absence +# of communication. This is useful for two reasons: +# +# 1) Detect dead peers. +# 2) Force network equipment in the middle to consider the connection to be +# alive. +# +# On Linux, the specified value (in seconds) is the period used to send ACKs. +# Note that to close the connection the double of the time is needed. +# On other kernels the period depends on the kernel configuration. +# +# A reasonable value for this option is 300 seconds, which is the new +# Redis default starting with Redis 3.2.1. +tcp-keepalive 300 + +# Apply OS-specific mechanism to mark the listening socket with the specified +# ID, to support advanced routing and filtering capabilities. +# +# On Linux, the ID represents a connection mark. +# On FreeBSD, the ID represents a socket cookie ID. +# On OpenBSD, the ID represents a route table ID. +# +# The default value is 0, which implies no marking is required. +# socket-mark-id 0 + +################################# TLS/SSL ##################################### + +# By default, TLS/SSL is disabled. To enable it, the "tls-port" configuration +# directive can be used to define TLS-listening ports. To enable TLS on the +# default port, use: +# +# port 0 +# tls-port 6379 + +# Configure a X.509 certificate and private key to use for authenticating the +# server to connected clients, masters or cluster peers. These files should be +# PEM formatted. +# +# tls-cert-file redis.crt +# tls-key-file redis.key +# +# If the key file is encrypted using a passphrase, it can be included here +# as well. +# +# tls-key-file-pass secret + +# Normally Redis uses the same certificate for both server functions (accepting +# connections) and client functions (replicating from a master, establishing +# cluster bus connections, etc.). +# +# Sometimes certificates are issued with attributes that designate them as +# client-only or server-only certificates. In that case it may be desired to use +# different certificates for incoming (server) and outgoing (client) +# connections. To do that, use the following directives: +# +# tls-client-cert-file client.crt +# tls-client-key-file client.key +# +# If the key file is encrypted using a passphrase, it can be included here +# as well. +# +# tls-client-key-file-pass secret + +# Configure a DH parameters file to enable Diffie-Hellman (DH) key exchange, +# required by older versions of OpenSSL (<3.0). Newer versions do not require +# this configuration and recommend against it. +# +# tls-dh-params-file redis.dh + +# Configure a CA certificate(s) bundle or directory to authenticate TLS/SSL +# clients and peers. Redis requires an explicit configuration of at least one +# of these, and will not implicitly use the system wide configuration. +# +# tls-ca-cert-file ca.crt +# tls-ca-cert-dir /etc/ssl/certs + +# By default, clients (including replica servers) on a TLS port are required +# to authenticate using valid client side certificates. +# +# If "no" is specified, client certificates are not required and not accepted. +# If "optional" is specified, client certificates are accepted and must be +# valid if provided, but are not required. +# +# tls-auth-clients no +# tls-auth-clients optional + +# By default, a Redis replica does not attempt to establish a TLS connection +# with its master. +# +# Use the following directive to enable TLS on replication links. +# +# tls-replication yes + +# By default, the Redis Cluster bus uses a plain TCP connection. To enable +# TLS for the bus protocol, use the following directive: +# +# tls-cluster yes + +# By default, only TLSv1.2 and TLSv1.3 are enabled and it is highly recommended +# that older formally deprecated versions are kept disabled to reduce the attack surface. +# You can explicitly specify TLS versions to support. +# Allowed values are case insensitive and include "TLSv1", "TLSv1.1", "TLSv1.2", +# "TLSv1.3" (OpenSSL >= 1.1.1) or any combination. +# To enable only TLSv1.2 and TLSv1.3, use: +# +# tls-protocols "TLSv1.2 TLSv1.3" + +# Configure allowed ciphers. See the ciphers(1ssl) manpage for more information +# about the syntax of this string. +# +# Note: this configuration applies only to <= TLSv1.2. +# +# tls-ciphers DEFAULT:!MEDIUM + +# Configure allowed TLSv1.3 ciphersuites. See the ciphers(1ssl) manpage for more +# information about the syntax of this string, and specifically for TLSv1.3 +# ciphersuites. +# +# tls-ciphersuites TLS_CHACHA20_POLY1305_SHA256 + +# When choosing a cipher, use the server's preference instead of the client +# preference. By default, the server follows the client's preference. +# +# tls-prefer-server-ciphers yes + +# By default, TLS session caching is enabled to allow faster and less expensive +# reconnections by clients that support it. Use the following directive to disable +# caching. +# +# tls-session-caching no + +# Change the default number of TLS sessions cached. A zero value sets the cache +# to unlimited size. The default size is 20480. +# +# tls-session-cache-size 5000 + +# Change the default timeout of cached TLS sessions. The default timeout is 300 +# seconds. +# +# tls-session-cache-timeout 60 + +################################# GENERAL ##################################### + +# By default Redis does not run as a daemon. Use 'yes' if you need it. +# Note that Redis will write a pid file in /var/run/redis.pid when daemonized. +# When Redis is supervised by upstart or systemd, this parameter has no impact. +daemonize no + +# If you run Redis from upstart or systemd, Redis can interact with your +# supervision tree. Options: +# supervised no - no supervision interaction +# supervised upstart - signal upstart by putting Redis into SIGSTOP mode +# requires "expect stop" in your upstart job config +# supervised systemd - signal systemd by writing READY=1 to $NOTIFY_SOCKET +# on startup, and updating Redis status on a regular +# basis. +# supervised auto - detect upstart or systemd method based on +# UPSTART_JOB or NOTIFY_SOCKET environment variables +# Note: these supervision methods only signal "process is ready." +# They do not enable continuous pings back to your supervisor. +# +# The default is "no". To run under upstart/systemd, you can simply uncomment +# the line below: +# +# supervised auto + +# If a pid file is specified, Redis writes it where specified at startup +# and removes it at exit. +# +# When the server runs non daemonized, no pid file is created if none is +# specified in the configuration. When the server is daemonized, the pid file +# is used even if not specified, defaulting to "/var/run/redis.pid". +# +# Creating a pid file is best effort: if Redis is not able to create it +# nothing bad happens, the server will start and run normally. +# +# Note that on modern Linux systems "/run/redis.pid" is more conforming +# and should be used instead. +pidfile /var/run/redis_6379.pid + +# Specify the server verbosity level. +# This can be one of: +# debug (a lot of information, useful for development/testing) +# verbose (many rarely useful info, but not a mess like the debug level) +# notice (moderately verbose, what you want in production probably) +# warning (only very important / critical messages are logged) +# nothing (nothing is logged) +loglevel notice + +# Specify the log file name. Also the empty string can be used to force +# Redis to log on the standard output. Note that if you use standard +# output for logging but daemonize, logs will be sent to /dev/null +logfile "" + +# To enable logging to the system logger, just set 'syslog-enabled' to yes, +# and optionally update the other syslog parameters to suit your needs. +# syslog-enabled no + +# Specify the syslog identity. +# syslog-ident redis + +# Specify the syslog facility. Must be USER or between LOCAL0-LOCAL7. +# syslog-facility local0 + +# To disable the built in crash log, which will possibly produce cleaner core +# dumps when they are needed, uncomment the following: +# +# crash-log-enabled no + +# To disable the fast memory check that's run as part of the crash log, which +# will possibly let redis terminate sooner, uncomment the following: +# +# crash-memcheck-enabled no + +# Set the number of databases. The default database is DB 0, you can select +# a different one on a per-connection basis using SELECT where +# dbid is a number between 0 and 'databases'-1 +databases 16 + +# By default Redis shows an ASCII art logo only when started to log to the +# standard output and if the standard output is a TTY and syslog logging is +# disabled. Basically this means that normally a logo is displayed only in +# interactive sessions. +# +# However it is possible to force the pre-4.0 behavior and always show a +# ASCII art logo in startup logs by setting the following option to yes. +always-show-logo no + +# By default, Redis modifies the process title (as seen in 'top' and 'ps') to +# provide some runtime information. It is possible to disable this and leave +# the process name as executed by setting the following to no. +set-proc-title yes + +# When changing the process title, Redis uses the following template to construct +# the modified title. +# +# Template variables are specified in curly brackets. The following variables are +# supported: +# +# {title} Name of process as executed if parent, or type of child process. +# {listen-addr} Bind address or '*' followed by TCP or TLS port listening on, or +# Unix socket if only that's available. +# {server-mode} Special mode, i.e. "[sentinel]" or "[cluster]". +# {port} TCP port listening on, or 0. +# {tls-port} TLS port listening on, or 0. +# {unixsocket} Unix domain socket listening on, or "". +# {config-file} Name of configuration file used. +# +proc-title-template "{title} {listen-addr} {server-mode}" + +# Set the local environment which is used for string comparison operations, and +# also affect the performance of Lua scripts. Empty String indicates the locale +# is derived from the environment variables. +locale-collate "" + +################################ SNAPSHOTTING ################################ + +# Save the DB to disk. +# +# save [ ...] +# +# Redis will save the DB if the given number of seconds elapsed and it +# surpassed the given number of write operations against the DB. +# +# Snapshotting can be completely disabled with a single empty string argument +# as in following example: +# +# save "" +# +# Unless specified otherwise, by default Redis will save the DB: +# * After 3600 seconds (an hour) if at least 1 change was performed +# * After 300 seconds (5 minutes) if at least 100 changes were performed +# * After 60 seconds if at least 10000 changes were performed +# +# You can set these explicitly by uncommenting the following line. +# +# save 3600 1 300 100 60 10000 + +# By default Redis will stop accepting writes if RDB snapshots are enabled +# (at least one save point) and the latest background save failed. +# This will make the user aware (in a hard way) that data is not persisting +# on disk properly, otherwise chances are that no one will notice and some +# disaster will happen. +# +# If the background saving process will start working again Redis will +# automatically allow writes again. +# +# However if you have setup your proper monitoring of the Redis server +# and persistence, you may want to disable this feature so that Redis will +# continue to work as usual even if there are problems with disk, +# permissions, and so forth. +stop-writes-on-bgsave-error yes + +# Compress string objects using LZF when dump .rdb databases? +# By default compression is enabled as it's almost always a win. +# If you want to save some CPU in the saving child set it to 'no' but +# the dataset will likely be bigger if you have compressible values or keys. +rdbcompression yes + +# Since version 5 of RDB a CRC64 checksum is placed at the end of the file. +# This makes the format more resistant to corruption but there is a performance +# hit to pay (around 10%) when saving and loading RDB files, so you can disable it +# for maximum performances. +# +# RDB files created with checksum disabled have a checksum of zero that will +# tell the loading code to skip the check. +rdbchecksum yes + +# Enables or disables full sanitization checks for ziplist and listpack etc when +# loading an RDB or RESTORE payload. This reduces the chances of a assertion or +# crash later on while processing commands. +# Options: +# no - Never perform full sanitization +# yes - Always perform full sanitization +# clients - Perform full sanitization only for user connections. +# Excludes: RDB files, RESTORE commands received from the master +# connection, and client connections which have the +# skip-sanitize-payload ACL flag. +# The default should be 'clients' but since it currently affects cluster +# resharding via MIGRATE, it is temporarily set to 'no' by default. +# +# sanitize-dump-payload no + +# The filename where to dump the DB +dbfilename dump.rdb + +# Remove RDB files used by replication in instances without persistence +# enabled. By default this option is disabled, however there are environments +# where for regulations or other security concerns, RDB files persisted on +# disk by masters in order to feed replicas, or stored on disk by replicas +# in order to load them for the initial synchronization, should be deleted +# ASAP. Note that this option ONLY WORKS in instances that have both AOF +# and RDB persistence disabled, otherwise is completely ignored. +# +# An alternative (and sometimes better) way to obtain the same effect is +# to use diskless replication on both master and replicas instances. However +# in the case of replicas, diskless is not always an option. +rdb-del-sync-files no + +# The working directory. +# +# The DB will be written inside this directory, with the filename specified +# above using the 'dbfilename' configuration directive. +# +# The Append Only File will also be created inside this directory. +# +# Note that you must specify a directory here, not a file name. +dir ./ + +################################# REPLICATION ################################# + +# Master-Replica replication. Use replicaof to make a Redis instance a copy of +# another Redis server. A few things to understand ASAP about Redis replication. +# +# +------------------+ +---------------+ +# | Master | ---> | Replica | +# | (receive writes) | | (exact copy) | +# +------------------+ +---------------+ +# +# 1) Redis replication is asynchronous, but you can configure a master to +# stop accepting writes if it appears to be not connected with at least +# a given number of replicas. +# 2) Redis replicas are able to perform a partial resynchronization with the +# master if the replication link is lost for a relatively small amount of +# time. You may want to configure the replication backlog size (see the next +# sections of this file) with a sensible value depending on your needs. +# 3) Replication is automatic and does not need user intervention. After a +# network partition replicas automatically try to reconnect to masters +# and resynchronize with them. +# +# replicaof + +# If the master is password protected (using the "requirepass" configuration +# directive below) it is possible to tell the replica to authenticate before +# starting the replication synchronization process, otherwise the master will +# refuse the replica request. +# +# masterauth +# +# However this is not enough if you are using Redis ACLs (for Redis version +# 6 or greater), and the default user is not capable of running the PSYNC +# command and/or other commands needed for replication. In this case it's +# better to configure a special user to use with replication, and specify the +# masteruser configuration as such: +# +# masteruser +# +# When masteruser is specified, the replica will authenticate against its +# master using the new AUTH form: AUTH . + +# When a replica loses its connection with the master, or when the replication +# is still in progress, the replica can act in two different ways: +# +# 1) if replica-serve-stale-data is set to 'yes' (the default) the replica will +# still reply to client requests, possibly with out of date data, or the +# data set may just be empty if this is the first synchronization. +# +# 2) If replica-serve-stale-data is set to 'no' the replica will reply with error +# "MASTERDOWN Link with MASTER is down and replica-serve-stale-data is set to 'no'" +# to all data access commands, excluding commands such as: +# INFO, REPLICAOF, AUTH, SHUTDOWN, REPLCONF, ROLE, CONFIG, SUBSCRIBE, +# UNSUBSCRIBE, PSUBSCRIBE, PUNSUBSCRIBE, PUBLISH, PUBSUB, COMMAND, POST, +# HOST and LATENCY. +# +replica-serve-stale-data yes + +# You can configure a replica instance to accept writes or not. Writing against +# a replica instance may be useful to store some ephemeral data (because data +# written on a replica will be easily deleted after resync with the master) but +# may also cause problems if clients are writing to it because of a +# misconfiguration. +# +# Since Redis 2.6 by default replicas are read-only. +# +# Note: read only replicas are not designed to be exposed to untrusted clients +# on the internet. It's just a protection layer against misuse of the instance. +# Still a read only replica exports by default all the administrative commands +# such as CONFIG, DEBUG, and so forth. To a limited extent you can improve +# security of read only replicas using 'rename-command' to shadow all the +# administrative / dangerous commands. +replica-read-only yes + +# Replication SYNC strategy: disk or socket. +# +# New replicas and reconnecting replicas that are not able to continue the +# replication process just receiving differences, need to do what is called a +# "full synchronization". An RDB file is transmitted from the master to the +# replicas. +# +# The transmission can happen in two different ways: +# +# 1) Disk-backed: The Redis master creates a new process that writes the RDB +# file on disk. Later the file is transferred by the parent +# process to the replicas incrementally. +# 2) Diskless: The Redis master creates a new process that directly writes the +# RDB file to replica sockets, without touching the disk at all. +# +# With disk-backed replication, while the RDB file is generated, more replicas +# can be queued and served with the RDB file as soon as the current child +# producing the RDB file finishes its work. With diskless replication instead +# once the transfer starts, new replicas arriving will be queued and a new +# transfer will start when the current one terminates. +# +# When diskless replication is used, the master waits a configurable amount of +# time (in seconds) before starting the transfer in the hope that multiple +# replicas will arrive and the transfer can be parallelized. +# +# With slow disks and fast (large bandwidth) networks, diskless replication +# works better. +repl-diskless-sync yes + +# When diskless replication is enabled, it is possible to configure the delay +# the server waits in order to spawn the child that transfers the RDB via socket +# to the replicas. +# +# This is important since once the transfer starts, it is not possible to serve +# new replicas arriving, that will be queued for the next RDB transfer, so the +# server waits a delay in order to let more replicas arrive. +# +# The delay is specified in seconds, and by default is 5 seconds. To disable +# it entirely just set it to 0 seconds and the transfer will start ASAP. +repl-diskless-sync-delay 5 + +# When diskless replication is enabled with a delay, it is possible to let +# the replication start before the maximum delay is reached if the maximum +# number of replicas expected have connected. Default of 0 means that the +# maximum is not defined and Redis will wait the full delay. +repl-diskless-sync-max-replicas 0 + +# ----------------------------------------------------------------------------- +# WARNING: Since in this setup the replica does not immediately store an RDB on +# disk, it may cause data loss during failovers. RDB diskless load + Redis +# modules not handling I/O reads may cause Redis to abort in case of I/O errors +# during the initial synchronization stage with the master. +# ----------------------------------------------------------------------------- +# +# Replica can load the RDB it reads from the replication link directly from the +# socket, or store the RDB to a file and read that file after it was completely +# received from the master. +# +# In many cases the disk is slower than the network, and storing and loading +# the RDB file may increase replication time (and even increase the master's +# Copy on Write memory and replica buffers). +# However, when parsing the RDB file directly from the socket, in order to avoid +# data loss it's only safe to flush the current dataset when the new dataset is +# fully loaded in memory, resulting in higher memory usage. +# For this reason we have the following options: +# +# "disabled" - Don't use diskless load (store the rdb file to the disk first) +# "swapdb" - Keep current db contents in RAM while parsing the data directly +# from the socket. Replicas in this mode can keep serving current +# dataset while replication is in progress, except for cases where +# they can't recognize master as having a data set from same +# replication history. +# Note that this requires sufficient memory, if you don't have it, +# you risk an OOM kill. +# "on-empty-db" - Use diskless load only when current dataset is empty. This is +# safer and avoid having old and new dataset loaded side by side +# during replication. +repl-diskless-load disabled + +# Master send PINGs to its replicas in a predefined interval. It's possible to +# change this interval with the repl_ping_replica_period option. The default +# value is 10 seconds. +# +# repl-ping-replica-period 10 + +# The following option sets the replication timeout for: +# +# 1) Bulk transfer I/O during SYNC, from the point of view of replica. +# 2) Master timeout from the point of view of replicas (data, pings). +# 3) Replica timeout from the point of view of masters (REPLCONF ACK pings). +# +# It is important to make sure that this value is greater than the value +# specified for repl-ping-replica-period otherwise a timeout will be detected +# every time there is low traffic between the master and the replica. The default +# value is 60 seconds. +# +# repl-timeout 60 + +# Disable TCP_NODELAY on the replica socket after SYNC? +# +# If you select "yes" Redis will use a smaller number of TCP packets and +# less bandwidth to send data to replicas. But this can add a delay for +# the data to appear on the replica side, up to 40 milliseconds with +# Linux kernels using a default configuration. +# +# If you select "no" the delay for data to appear on the replica side will +# be reduced but more bandwidth will be used for replication. +# +# By default we optimize for low latency, but in very high traffic conditions +# or when the master and replicas are many hops away, turning this to "yes" may +# be a good idea. +repl-disable-tcp-nodelay no + +# Set the replication backlog size. The backlog is a buffer that accumulates +# replica data when replicas are disconnected for some time, so that when a +# replica wants to reconnect again, often a full resync is not needed, but a +# partial resync is enough, just passing the portion of data the replica +# missed while disconnected. +# +# The bigger the replication backlog, the longer the replica can endure the +# disconnect and later be able to perform a partial resynchronization. +# +# The backlog is only allocated if there is at least one replica connected. +# +# repl-backlog-size 1mb + +# After a master has no connected replicas for some time, the backlog will be +# freed. The following option configures the amount of seconds that need to +# elapse, starting from the time the last replica disconnected, for the backlog +# buffer to be freed. +# +# Note that replicas never free the backlog for timeout, since they may be +# promoted to masters later, and should be able to correctly "partially +# resynchronize" with other replicas: hence they should always accumulate backlog. +# +# A value of 0 means to never release the backlog. +# +# repl-backlog-ttl 3600 + +# The replica priority is an integer number published by Redis in the INFO +# output. It is used by Redis Sentinel in order to select a replica to promote +# into a master if the master is no longer working correctly. +# +# A replica with a low priority number is considered better for promotion, so +# for instance if there are three replicas with priority 10, 100, 25 Sentinel +# will pick the one with priority 10, that is the lowest. +# +# However a special priority of 0 marks the replica as not able to perform the +# role of master, so a replica with priority of 0 will never be selected by +# Redis Sentinel for promotion. +# +# By default the priority is 100. +replica-priority 100 + +# The propagation error behavior controls how Redis will behave when it is +# unable to handle a command being processed in the replication stream from a master +# or processed while reading from an AOF file. Errors that occur during propagation +# are unexpected, and can cause data inconsistency. However, there are edge cases +# in earlier versions of Redis where it was possible for the server to replicate or persist +# commands that would fail on future versions. For this reason the default behavior +# is to ignore such errors and continue processing commands. +# +# If an application wants to ensure there is no data divergence, this configuration +# should be set to 'panic' instead. The value can also be set to 'panic-on-replicas' +# to only panic when a replica encounters an error on the replication stream. One of +# these two panic values will become the default value in the future once there are +# sufficient safety mechanisms in place to prevent false positive crashes. +# +# propagation-error-behavior ignore + +# Replica ignore disk write errors controls the behavior of a replica when it is +# unable to persist a write command received from its master to disk. By default, +# this configuration is set to 'no' and will crash the replica in this condition. +# It is not recommended to change this default, however in order to be compatible +# with older versions of Redis this config can be toggled to 'yes' which will just +# log a warning and execute the write command it got from the master. +# +# replica-ignore-disk-write-errors no + +# ----------------------------------------------------------------------------- +# By default, Redis Sentinel includes all replicas in its reports. A replica +# can be excluded from Redis Sentinel's announcements. An unannounced replica +# will be ignored by the 'sentinel replicas ' command and won't be +# exposed to Redis Sentinel's clients. +# +# This option does not change the behavior of replica-priority. Even with +# replica-announced set to 'no', the replica can be promoted to master. To +# prevent this behavior, set replica-priority to 0. +# +# replica-announced yes + +# It is possible for a master to stop accepting writes if there are less than +# N replicas connected, having a lag less or equal than M seconds. +# +# The N replicas need to be in "online" state. +# +# The lag in seconds, that must be <= the specified value, is calculated from +# the last ping received from the replica, that is usually sent every second. +# +# This option does not GUARANTEE that N replicas will accept the write, but +# will limit the window of exposure for lost writes in case not enough replicas +# are available, to the specified number of seconds. +# +# For example to require at least 3 replicas with a lag <= 10 seconds use: +# +# min-replicas-to-write 3 +# min-replicas-max-lag 10 +# +# Setting one or the other to 0 disables the feature. +# +# By default min-replicas-to-write is set to 0 (feature disabled) and +# min-replicas-max-lag is set to 10. + +# A Redis master is able to list the address and port of the attached +# replicas in different ways. For example the "INFO replication" section +# offers this information, which is used, among other tools, by +# Redis Sentinel in order to discover replica instances. +# Another place where this info is available is in the output of the +# "ROLE" command of a master. +# +# The listed IP address and port normally reported by a replica is +# obtained in the following way: +# +# IP: The address is auto detected by checking the peer address +# of the socket used by the replica to connect with the master. +# +# Port: The port is communicated by the replica during the replication +# handshake, and is normally the port that the replica is using to +# listen for connections. +# +# However when port forwarding or Network Address Translation (NAT) is +# used, the replica may actually be reachable via different IP and port +# pairs. The following two options can be used by a replica in order to +# report to its master a specific set of IP and port, so that both INFO +# and ROLE will report those values. +# +# There is no need to use both the options if you need to override just +# the port or the IP address. +# +# replica-announce-ip 5.5.5.5 +# replica-announce-port 1234 + +############################### KEYS TRACKING ################################# + +# Redis implements server assisted support for client side caching of values. +# This is implemented using an invalidation table that remembers, using +# a radix key indexed by key name, what clients have which keys. In turn +# this is used in order to send invalidation messages to clients. Please +# check this page to understand more about the feature: +# +# https://redis.io/topics/client-side-caching +# +# When tracking is enabled for a client, all the read only queries are assumed +# to be cached: this will force Redis to store information in the invalidation +# table. When keys are modified, such information is flushed away, and +# invalidation messages are sent to the clients. However if the workload is +# heavily dominated by reads, Redis could use more and more memory in order +# to track the keys fetched by many clients. +# +# For this reason it is possible to configure a maximum fill value for the +# invalidation table. By default it is set to 1M of keys, and once this limit +# is reached, Redis will start to evict keys in the invalidation table +# even if they were not modified, just to reclaim memory: this will in turn +# force the clients to invalidate the cached values. Basically the table +# maximum size is a trade off between the memory you want to spend server +# side to track information about who cached what, and the ability of clients +# to retain cached objects in memory. +# +# If you set the value to 0, it means there are no limits, and Redis will +# retain as many keys as needed in the invalidation table. +# In the "stats" INFO section, you can find information about the number of +# keys in the invalidation table at every given moment. +# +# Note: when key tracking is used in broadcasting mode, no memory is used +# in the server side so this setting is useless. +# +# tracking-table-max-keys 1000000 + +################################## SECURITY ################################### + +# Warning: since Redis is pretty fast, an outside user can try up to +# 1 million passwords per second against a modern box. This means that you +# should use very strong passwords, otherwise they will be very easy to break. +# Note that because the password is really a platform secret between the client +# and the server, and should not be memorized by any human, the password +# can be easily a long string from /dev/urandom or whatever, so by using a +# long and unguessable password no brute force attack will be possible. + +# Redis ACL users are defined in the following format: +# +# user ... acl rules ... +# +# For example: +# +# user worker +@list +@connection ~jobs:* on >ffa9203c493aa99 +# +# The special username "default" is used for new connections. If this user +# has the "nopass" rule, then new connections will be immediately authenticated +# as the "default" user without the need of any password provided via the +# AUTH command. Otherwise if the "default" user is not flagged with "nopass" +# the connections will start in not authenticated state, and will require +# AUTH (or the HELLO command AUTH option) in order to be authenticated and +# start to work. +# +# The ACL rules that describe what a user can do are the following: +# +# on Enable the user: it is possible to authenticate as this user. +# off Disable the user: it's no longer possible to authenticate +# with this user, however the already authenticated connections +# will still work. +# skip-sanitize-payload RESTORE dump-payload sanitization is skipped. +# sanitize-payload RESTORE dump-payload is sanitized (default). +# + Allow the execution of that command. +# May be used with `|` for allowing subcommands (e.g "+config|get") +# - Disallow the execution of that command. +# May be used with `|` for blocking subcommands (e.g "-config|set") +# +@ Allow the execution of all the commands in such category +# with valid categories are like @admin, @set, @sortedset, ... +# and so forth, see the full list in the server.c file where +# the Redis command table is described and defined. +# The special category @all means all the commands, but currently +# present in the server, and that will be loaded in the future +# via modules. +# +|first-arg Allow a specific first argument of an otherwise +# disabled command. It is only supported on commands with +# no sub-commands, and is not allowed as negative form +# like -SELECT|1, only additive starting with "+". This +# feature is deprecated and may be removed in the future. +# allcommands Alias for +@all. Note that it implies the ability to execute +# all the future commands loaded via the modules system. +# nocommands Alias for -@all. +# ~ Add a pattern of keys that can be mentioned as part of +# commands. For instance ~* allows all the keys. The pattern +# is a glob-style pattern like the one of KEYS. +# It is possible to specify multiple patterns. +# %R~ Add key read pattern that specifies which keys can be read +# from. +# %W~ Add key write pattern that specifies which keys can be +# written to. +# allkeys Alias for ~* +# resetkeys Flush the list of allowed keys patterns. +# & Add a glob-style pattern of Pub/Sub channels that can be +# accessed by the user. It is possible to specify multiple channel +# patterns. +# allchannels Alias for &* +# resetchannels Flush the list of allowed channel patterns. +# > Add this password to the list of valid password for the user. +# For example >mypass will add "mypass" to the list. +# This directive clears the "nopass" flag (see later). +# < Remove this password from the list of valid passwords. +# nopass All the set passwords of the user are removed, and the user +# is flagged as requiring no password: it means that every +# password will work against this user. If this directive is +# used for the default user, every new connection will be +# immediately authenticated with the default user without +# any explicit AUTH command required. Note that the "resetpass" +# directive will clear this condition. +# resetpass Flush the list of allowed passwords. Moreover removes the +# "nopass" status. After "resetpass" the user has no associated +# passwords and there is no way to authenticate without adding +# some password (or setting it as "nopass" later). +# reset Performs the following actions: resetpass, resetkeys, resetchannels, +# allchannels (if acl-pubsub-default is set), off, clearselectors, -@all. +# The user returns to the same state it has immediately after its creation. +# () Create a new selector with the options specified within the +# parentheses and attach it to the user. Each option should be +# space separated. The first character must be ( and the last +# character must be ). +# clearselectors Remove all of the currently attached selectors. +# Note this does not change the "root" user permissions, +# which are the permissions directly applied onto the +# user (outside the parentheses). +# +# ACL rules can be specified in any order: for instance you can start with +# passwords, then flags, or key patterns. However note that the additive +# and subtractive rules will CHANGE MEANING depending on the ordering. +# For instance see the following example: +# +# user alice on +@all -DEBUG ~* >somepassword +# +# This will allow "alice" to use all the commands with the handler of the +# DEBUG command, since +@all added all the commands to the set of the commands +# alice can use, and later DEBUG was removed. However if we invert the order +# of two ACL rules the result will be different: +# +# user alice on -DEBUG +@all ~* >somepassword +# +# Now DEBUG was removed when alice had yet no commands in the set of allowed +# commands, later all the commands are added, so the user will be able to +# execute everything. +# +# Basically ACL rules are processed left-to-right. +# +# The following is a list of command categories and their meanings: +# * keyspace - Writing or reading from keys, databases, or their metadata +# in a type agnostic way. Includes DEL, RESTORE, DUMP, RENAME, EXISTS, DBSIZE, +# KEYS, EXPIRE, TTL, FLUSHALL, etc. Commands that may modify the keyspace, +# key or metadata will also have `write` category. Commands that only read +# the keyspace, key or metadata will have the `read` category. +# * read - Reading from keys (values or metadata). Note that commands that don't +# interact with keys, will not have either `read` or `write`. +# * write - Writing to keys (values or metadata) +# * admin - Administrative commands. Normal applications will never need to use +# these. Includes REPLICAOF, CONFIG, DEBUG, SAVE, MONITOR, ACL, SHUTDOWN, etc. +# * dangerous - Potentially dangerous (each should be considered with care for +# various reasons). This includes FLUSHALL, MIGRATE, RESTORE, SORT, KEYS, +# CLIENT, DEBUG, INFO, CONFIG, SAVE, REPLICAOF, etc. +# * connection - Commands affecting the connection or other connections. +# This includes AUTH, SELECT, COMMAND, CLIENT, ECHO, PING, etc. +# * blocking - Potentially blocking the connection until released by another +# command. +# * fast - Fast O(1) commands. May loop on the number of arguments, but not the +# number of elements in the key. +# * slow - All commands that are not Fast. +# * pubsub - PUBLISH / SUBSCRIBE related +# * transaction - WATCH / MULTI / EXEC related commands. +# * scripting - Scripting related. +# * set - Data type: sets related. +# * sortedset - Data type: zsets related. +# * list - Data type: lists related. +# * hash - Data type: hashes related. +# * string - Data type: strings related. +# * bitmap - Data type: bitmaps related. +# * hyperloglog - Data type: hyperloglog related. +# * geo - Data type: geo related. +# * stream - Data type: streams related. +# +# For more information about ACL configuration please refer to +# the Redis web site at https://redis.io/topics/acl + +# ACL LOG +# +# The ACL Log tracks failed commands and authentication events associated +# with ACLs. The ACL Log is useful to troubleshoot failed commands blocked +# by ACLs. The ACL Log is stored in memory. You can reclaim memory with +# ACL LOG RESET. Define the maximum entry length of the ACL Log below. +acllog-max-len 128 + +# Using an external ACL file +# +# Instead of configuring users here in this file, it is possible to use +# a stand-alone file just listing users. The two methods cannot be mixed: +# if you configure users here and at the same time you activate the external +# ACL file, the server will refuse to start. +# +# The format of the external ACL user file is exactly the same as the +# format that is used inside redis.conf to describe users. +# +# aclfile /etc/redis/users.acl + +# IMPORTANT NOTE: starting with Redis 6 "requirepass" is just a compatibility +# layer on top of the new ACL system. The option effect will be just setting +# the password for the default user. Clients will still authenticate using +# AUTH as usually, or more explicitly with AUTH default +# if they follow the new protocol: both will work. +# +# The requirepass is not compatible with aclfile option and the ACL LOAD +# command, these will cause requirepass to be ignored. +# +# requirepass foobared + +# New users are initialized with restrictive permissions by default, via the +# equivalent of this ACL rule 'off resetkeys -@all'. Starting with Redis 6.2, it +# is possible to manage access to Pub/Sub channels with ACL rules as well. The +# default Pub/Sub channels permission if new users is controlled by the +# acl-pubsub-default configuration directive, which accepts one of these values: +# +# allchannels: grants access to all Pub/Sub channels +# resetchannels: revokes access to all Pub/Sub channels +# +# From Redis 7.0, acl-pubsub-default defaults to 'resetchannels' permission. +# +# acl-pubsub-default resetchannels + +# Command renaming (DEPRECATED). +# +# ------------------------------------------------------------------------ +# WARNING: avoid using this option if possible. Instead use ACLs to remove +# commands from the default user, and put them only in some admin user you +# create for administrative purposes. +# ------------------------------------------------------------------------ +# +# It is possible to change the name of dangerous commands in a platform +# environment. For instance the CONFIG command may be renamed into something +# hard to guess so that it will still be available for internal-use tools +# but not available for general clients. +# +# Example: +# +# rename-command CONFIG b840fc02d524045429941cc15f59e41cb7be6c52 +# +# It is also possible to completely kill a command by renaming it into +# an empty string: +# +# rename-command CONFIG "" +# +# Please note that changing the name of commands that are logged into the +# AOF file or transmitted to replicas may cause problems. + +################################### CLIENTS #################################### + +# Set the max number of connected clients at the same time. By default +# this limit is set to 10000 clients, however if the Redis server is not +# able to configure the process file limit to allow for the specified limit +# the max number of allowed clients is set to the current file limit +# minus 32 (as Redis reserves a few file descriptors for internal uses). +# +# Once the limit is reached Redis will close all the new connections sending +# an error 'max number of clients reached'. +# +# IMPORTANT: When Redis Cluster is used, the max number of connections is also +# platform with the cluster bus: every node in the cluster will use two +# connections, one incoming and another outgoing. It is important to size the +# limit accordingly in case of very large clusters. +# +# maxclients 10000 + +############################## MEMORY MANAGEMENT ################################ + +# Set a memory usage limit to the specified amount of bytes. +# When the memory limit is reached Redis will try to remove keys +# according to the eviction policy selected (see maxmemory-policy). +# +# If Redis can't remove keys according to the policy, or if the policy is +# set to 'noeviction', Redis will start to reply with errors to commands +# that would use more memory, like SET, LPUSH, and so on, and will continue +# to reply to read-only commands like GET. +# +# This option is usually useful when using Redis as an LRU or LFU cache, or to +# set a hard memory limit for an instance (using the 'noeviction' policy). +# +# WARNING: If you have replicas attached to an instance with maxmemory on, +# the size of the output buffers needed to feed the replicas are subtracted +# from the used memory count, so that network problems / resyncs will +# not trigger a loop where keys are evicted, and in turn the output +# buffer of replicas is full with DELs of keys evicted triggering the deletion +# of more keys, and so forth until the database is completely emptied. +# +# In short... if you have replicas attached it is suggested that you set a lower +# limit for maxmemory so that there is some free RAM on the system for replica +# output buffers (but this is not needed if the policy is 'noeviction'). +# +# maxmemory + +# MAXMEMORY POLICY: how Redis will select what to remove when maxmemory +# is reached. You can select one from the following behaviors: +# +# volatile-lru -> Evict using approximated LRU, only keys with an expire set. +# allkeys-lru -> Evict any key using approximated LRU. +# volatile-lfu -> Evict using approximated LFU, only keys with an expire set. +# allkeys-lfu -> Evict any key using approximated LFU. +# volatile-random -> Remove a random key having an expire set. +# allkeys-random -> Remove a random key, any key. +# volatile-ttl -> Remove the key with the nearest expire time (minor TTL) +# noeviction -> Don't evict anything, just return an error on write operations. +# +# LRU means Least Recently Used +# LFU means Least Frequently Used +# +# Both LRU, LFU and volatile-ttl are implemented using approximated +# randomized algorithms. +# +# Note: with any of the above policies, when there are no suitable keys for +# eviction, Redis will return an error on write operations that require +# more memory. These are usually commands that create new keys, add data or +# modify existing keys. A few examples are: SET, INCR, HSET, LPUSH, SUNIONSTORE, +# SORT (due to the STORE argument), and EXEC (if the transaction includes any +# command that requires memory). +# +# The default is: +# +# maxmemory-policy noeviction + +# LRU, LFU and minimal TTL algorithms are not precise algorithms but approximated +# algorithms (in order to save memory), so you can tune it for speed or +# accuracy. By default Redis will check five keys and pick the one that was +# used least recently, you can change the sample size using the following +# configuration directive. +# +# The default of 5 produces good enough results. 10 Approximates very closely +# true LRU but costs more CPU. 3 is faster but not very accurate. +# +# maxmemory-samples 5 + +# Eviction processing is designed to function well with the default setting. +# If there is an unusually large amount of write traffic, this value may need to +# be increased. Decreasing this value may reduce latency at the risk of +# eviction processing effectiveness +# 0 = minimum latency, 10 = default, 100 = process without regard to latency +# +# maxmemory-eviction-tenacity 10 + +# Starting from Redis 5, by default a replica will ignore its maxmemory setting +# (unless it is promoted to master after a failover or manually). It means +# that the eviction of keys will be just handled by the master, sending the +# DEL commands to the replica as keys evict in the master side. +# +# This behavior ensures that masters and replicas stay consistent, and is usually +# what you want, however if your replica is writable, or you want the replica +# to have a different memory setting, and you are sure all the writes performed +# to the replica are idempotent, then you may change this default (but be sure +# to understand what you are doing). +# +# Note that since the replica by default does not evict, it may end using more +# memory than the one set via maxmemory (there are certain buffers that may +# be larger on the replica, or data structures may sometimes take more memory +# and so forth). So make sure you monitor your replicas and make sure they +# have enough memory to never hit a real out-of-memory condition before the +# master hits the configured maxmemory setting. +# +# replica-ignore-maxmemory yes + +# Redis reclaims expired keys in two ways: upon access when those keys are +# found to be expired, and also in background, in what is called the +# "active expire key". The key space is slowly and interactively scanned +# looking for expired keys to reclaim, so that it is possible to free memory +# of keys that are expired and will never be accessed again in a short time. +# +# The default effort of the expire cycle will try to avoid having more than +# ten percent of expired keys still in memory, and will try to avoid consuming +# more than 25% of total memory and to add latency to the system. However +# it is possible to increase the expire "effort" that is normally set to +# "1", to a greater value, up to the value "10". At its maximum value the +# system will use more CPU, longer cycles (and technically may introduce +# more latency), and will tolerate less already expired keys still present +# in the system. It's a tradeoff between memory, CPU and latency. +# +# active-expire-effort 1 + +############################# LAZY FREEING #################################### + +# Redis has two primitives to delete keys. One is called DEL and is a blocking +# deletion of the object. It means that the server stops processing new commands +# in order to reclaim all the memory associated with an object in a synchronous +# way. If the key deleted is associated with a small object, the time needed +# in order to execute the DEL command is very small and comparable to most other +# O(1) or O(log_N) commands in Redis. However if the key is associated with an +# aggregated value containing millions of elements, the server can block for +# a long time (even seconds) in order to complete the operation. +# +# For the above reasons Redis also offers non blocking deletion primitives +# such as UNLINK (non blocking DEL) and the ASYNC option of FLUSHALL and +# FLUSHDB commands, in order to reclaim memory in background. Those commands +# are executed in constant time. Another thread will incrementally free the +# object in the background as fast as possible. +# +# DEL, UNLINK and ASYNC option of FLUSHALL and FLUSHDB are user-controlled. +# It's up to the design of the application to understand when it is a good +# idea to use one or the other. However the Redis server sometimes has to +# delete keys or flush the whole database as a side effect of other operations. +# Specifically Redis deletes objects independently of a user call in the +# following scenarios: +# +# 1) On eviction, because of the maxmemory and maxmemory policy configurations, +# in order to make room for new data, without going over the specified +# memory limit. +# 2) Because of expire: when a key with an associated time to live (see the +# EXPIRE command) must be deleted from memory. +# 3) Because of a side effect of a command that stores data on a key that may +# already exist. For example the RENAME command may delete the old key +# content when it is replaced with another one. Similarly SUNIONSTORE +# or SORT with STORE option may delete existing keys. The SET command +# itself removes any old content of the specified key in order to replace +# it with the specified string. +# 4) During replication, when a replica performs a full resynchronization with +# its master, the content of the whole database is removed in order to +# load the RDB file just transferred. +# +# In all the above cases the default is to delete objects in a blocking way, +# like if DEL was called. However you can configure each case specifically +# in order to instead release memory in a non-blocking way like if UNLINK +# was called, using the following configuration directives. + +lazyfree-lazy-eviction no +lazyfree-lazy-expire no +lazyfree-lazy-server-del no +replica-lazy-flush no + +# It is also possible, for the case when to replace the user code DEL calls +# with UNLINK calls is not easy, to modify the default behavior of the DEL +# command to act exactly like UNLINK, using the following configuration +# directive: + +lazyfree-lazy-user-del no + +# FLUSHDB, FLUSHALL, SCRIPT FLUSH and FUNCTION FLUSH support both asynchronous and synchronous +# deletion, which can be controlled by passing the [SYNC|ASYNC] flags into the +# commands. When neither flag is passed, this directive will be used to determine +# if the data should be deleted asynchronously. + +lazyfree-lazy-user-flush no + +################################ THREADED I/O ################################# + +# Redis is mostly single threaded, however there are certain threaded +# operations such as UNLINK, slow I/O accesses and other things that are +# performed on side threads. +# +# Now it is also possible to handle Redis clients socket reads and writes +# in different I/O threads. Since especially writing is so slow, normally +# Redis users use pipelining in order to speed up the Redis performances per +# core, and spawn multiple instances in order to scale more. Using I/O +# threads it is possible to easily speedup two times Redis without resorting +# to pipelining nor sharding of the instance. +# +# By default threading is disabled, we suggest enabling it only in machines +# that have at least 4 or more cores, leaving at least one spare core. +# Using more than 8 threads is unlikely to help much. We also recommend using +# threaded I/O only if you actually have performance problems, with Redis +# instances being able to use a quite big percentage of CPU time, otherwise +# there is no point in using this feature. +# +# So for instance if you have a four cores boxes, try to use 2 or 3 I/O +# threads, if you have a 8 cores, try to use 6 threads. In order to +# enable I/O threads use the following configuration directive: +# +# io-threads 4 +# +# Setting io-threads to 1 will just use the main thread as usual. +# When I/O threads are enabled, we only use threads for writes, that is +# to thread the write(2) syscall and transfer the client buffers to the +# socket. However it is also possible to enable threading of reads and +# protocol parsing using the following configuration directive, by setting +# it to yes: +# +# io-threads-do-reads no +# +# Usually threading reads doesn't help much. +# +# NOTE 1: This configuration directive cannot be changed at runtime via +# CONFIG SET. Also, this feature currently does not work when SSL is +# enabled. +# +# NOTE 2: If you want to test the Redis speedup using redis-benchmark, make +# sure you also run the benchmark itself in threaded mode, using the +# --threads option to match the number of Redis threads, otherwise you'll not +# be able to notice the improvements. + +############################ KERNEL OOM CONTROL ############################## + +# On Linux, it is possible to hint the kernel OOM killer on what processes +# should be killed first when out of memory. +# +# Enabling this feature makes Redis actively control the oom_score_adj value +# for all its processes, depending on their role. The default scores will +# attempt to have background child processes killed before all others, and +# replicas killed before masters. +# +# Redis supports these options: +# +# no: Don't make changes to oom-score-adj (default). +# yes: Alias to "relative" see below. +# absolute: Values in oom-score-adj-values are written as is to the kernel. +# relative: Values are used relative to the initial value of oom_score_adj when +# the server starts and are then clamped to a range of -1000 to 1000. +# Because typically the initial value is 0, they will often match the +# absolute values. +oom-score-adj no + +# When oom-score-adj is used, this directive controls the specific values used +# for master, replica and background child processes. Values range -2000 to +# 2000 (higher means more likely to be killed). +# +# Unprivileged processes (not root, and without CAP_SYS_RESOURCE capabilities) +# can freely increase their value, but not decrease it below its initial +# settings. This means that setting oom-score-adj to "relative" and setting the +# oom-score-adj-values to positive values will always succeed. +oom-score-adj-values 0 200 800 + + +#################### KERNEL transparent hugepage CONTROL ###################### + +# Usually the kernel Transparent Huge Pages control is set to "madvise" or +# or "never" by default (/sys/kernel/mm/transparent_hugepage/enabled), in which +# case this config has no effect. On systems in which it is set to "always", +# redis will attempt to disable it specifically for the redis process in order +# to avoid latency problems specifically with fork(2) and CoW. +# If for some reason you prefer to keep it enabled, you can set this config to +# "no" and the kernel global to "always". + +disable-thp yes + +############################## APPEND ONLY MODE ############################### + +# By default Redis asynchronously dumps the dataset on disk. This mode is +# good enough in many applications, but an issue with the Redis process or +# a power outage may result into a few minutes of writes lost (depending on +# the configured save points). +# +# The Append Only File is an alternative persistence mode that provides +# much better durability. For instance using the default data fsync policy +# (see later in the config file) Redis can lose just one second of writes in a +# dramatic event like a server power outage, or a single write if something +# wrong with the Redis process itself happens, but the operating system is +# still running correctly. +# +# AOF and RDB persistence can be enabled at the same time without problems. +# If the AOF is enabled on startup Redis will load the AOF, that is the file +# with the better durability guarantees. +# +# Please check https://redis.io/topics/persistence for more information. + +appendonly no + +# The base name of the append only file. +# +# Redis 7 and newer use a set of append-only files to persist the dataset +# and changes applied to it. There are two basic types of files in use: +# +# - Base files, which are a snapshot representing the complete state of the +# dataset at the time the file was created. Base files can be either in +# the form of RDB (binary serialized) or AOF (textual commands). +# - Incremental files, which contain additional commands that were applied +# to the dataset following the previous file. +# +# In addition, manifest files are used to track the files and the order in +# which they were created and should be applied. +# +# Append-only file names are created by Redis following a specific pattern. +# The file name's prefix is based on the 'appendfilename' configuration +# parameter, followed by additional information about the sequence and type. +# +# For example, if appendfilename is set to appendonly.aof, the following file +# names could be derived: +# +# - appendonly.aof.1.base.rdb as a base file. +# - appendonly.aof.1.incr.aof, appendonly.aof.2.incr.aof as incremental files. +# - appendonly.aof.manifest as a manifest file. + +appendfilename "appendonly.aof" + +# For convenience, Redis stores all persistent append-only files in a dedicated +# directory. The name of the directory is determined by the appenddirname +# configuration parameter. + +appenddirname "appendonlydir" + +# The fsync() call tells the Operating System to actually write data on disk +# instead of waiting for more data in the output buffer. Some OS will really flush +# data on disk, some other OS will just try to do it ASAP. +# +# Redis supports three different modes: +# +# no: don't fsync, just let the OS flush the data when it wants. Faster. +# always: fsync after every write to the append only log. Slow, Safest. +# everysec: fsync only one time every second. Compromise. +# +# The default is "everysec", as that's usually the right compromise between +# speed and data safety. It's up to you to understand if you can relax this to +# "no" that will let the operating system flush the output buffer when +# it wants, for better performances (but if you can live with the idea of +# some data loss consider the default persistence mode that's snapshotting), +# or on the contrary, use "always" that's very slow but a bit safer than +# everysec. +# +# More details please check the following article: +# http://antirez.com/post/redis-persistence-demystified.html +# +# If unsure, use "everysec". + +# appendfsync always +appendfsync everysec +# appendfsync no + +# When the AOF fsync policy is set to always or everysec, and a background +# saving process (a background save or AOF log background rewriting) is +# performing a lot of I/O against the disk, in some Linux configurations +# Redis may block too long on the fsync() call. Note that there is no fix for +# this currently, as even performing fsync in a different thread will block +# our synchronous write(2) call. +# +# In order to mitigate this problem it's possible to use the following option +# that will prevent fsync() from being called in the main process while a +# BGSAVE or BGREWRITEAOF is in progress. +# +# This means that while another child is saving, the durability of Redis is +# the same as "appendfsync no". In practical terms, this means that it is +# possible to lose up to 30 seconds of log in the worst scenario (with the +# default Linux settings). +# +# If you have latency problems turn this to "yes". Otherwise leave it as +# "no" that is the safest pick from the point of view of durability. + +no-appendfsync-on-rewrite no + +# Automatic rewrite of the append only file. +# Redis is able to automatically rewrite the log file implicitly calling +# BGREWRITEAOF when the AOF log size grows by the specified percentage. +# +# This is how it works: Redis remembers the size of the AOF file after the +# latest rewrite (if no rewrite has happened since the restart, the size of +# the AOF at startup is used). +# +# This base size is compared to the current size. If the current size is +# bigger than the specified percentage, the rewrite is triggered. Also +# you need to specify a minimal size for the AOF file to be rewritten, this +# is useful to avoid rewriting the AOF file even if the percentage increase +# is reached but it is still pretty small. +# +# Specify a percentage of zero in order to disable the automatic AOF +# rewrite feature. + +auto-aof-rewrite-percentage 100 +auto-aof-rewrite-min-size 64mb + +# An AOF file may be found to be truncated at the end during the Redis +# startup process, when the AOF data gets loaded back into memory. +# This may happen when the system where Redis is running +# crashes, especially when an ext4 filesystem is mounted without the +# data=ordered option (however this can't happen when Redis itself +# crashes or aborts but the operating system still works correctly). +# +# Redis can either exit with an error when this happens, or load as much +# data as possible (the default now) and start if the AOF file is found +# to be truncated at the end. The following option controls this behavior. +# +# If aof-load-truncated is set to yes, a truncated AOF file is loaded and +# the Redis server starts emitting a log to inform the user of the event. +# Otherwise if the option is set to no, the server aborts with an error +# and refuses to start. When the option is set to no, the user requires +# to fix the AOF file using the "redis-check-aof" utility before to restart +# the server. +# +# Note that if the AOF file will be found to be corrupted in the middle +# the server will still exit with an error. This option only applies when +# Redis will try to read more data from the AOF file but not enough bytes +# will be found. +aof-load-truncated yes + +# Redis can create append-only base files in either RDB or AOF formats. Using +# the RDB format is always faster and more efficient, and disabling it is only +# supported for backward compatibility purposes. +aof-use-rdb-preamble yes + +# Redis supports recording timestamp annotations in the AOF to support restoring +# the data from a specific point-in-time. However, using this capability changes +# the AOF format in a way that may not be compatible with existing AOF parsers. +aof-timestamp-enabled no + +################################ SHUTDOWN ##################################### + +# Maximum time to wait for replicas when shutting down, in seconds. +# +# During shut down, a grace period allows any lagging replicas to catch up with +# the latest replication offset before the master exists. This period can +# prevent data loss, especially for deployments without configured disk backups. +# +# The 'shutdown-timeout' value is the grace period's duration in seconds. It is +# only applicable when the instance has replicas. To disable the feature, set +# the value to 0. +# +# shutdown-timeout 10 + +# When Redis receives a SIGINT or SIGTERM, shutdown is initiated and by default +# an RDB snapshot is written to disk in a blocking operation if save points are configured. +# The options used on signaled shutdown can include the following values: +# default: Saves RDB snapshot only if save points are configured. +# Waits for lagging replicas to catch up. +# save: Forces a DB saving operation even if no save points are configured. +# nosave: Prevents DB saving operation even if one or more save points are configured. +# now: Skips waiting for lagging replicas. +# force: Ignores any errors that would normally prevent the server from exiting. +# +# Any combination of values is allowed as long as "save" and "nosave" are not set simultaneously. +# Example: "nosave force now" +# +# shutdown-on-sigint default +# shutdown-on-sigterm default + +################ NON-DETERMINISTIC LONG BLOCKING COMMANDS ##################### + +# Maximum time in milliseconds for EVAL scripts, functions and in some cases +# modules' commands before Redis can start processing or rejecting other clients. +# +# If the maximum execution time is reached Redis will start to reply to most +# commands with a BUSY error. +# +# In this state Redis will only allow a handful of commands to be executed. +# For instance, SCRIPT KILL, FUNCTION KILL, SHUTDOWN NOSAVE and possibly some +# business specific 'allow-busy' commands. +# +# SCRIPT KILL and FUNCTION KILL will only be able to stop a script that did not +# yet call any write commands, so SHUTDOWN NOSAVE may be the only way to stop +# the server in the case a write command was already issued by the script when +# the user doesn't want to wait for the natural termination of the script. +# +# The default is 5 seconds. It is possible to set it to 0 or a negative value +# to disable this mechanism (uninterrupted execution). Note that in the past +# this config had a different name, which is now an alias, so both of these do +# the same: +# lua-time-limit 5000 +# busy-reply-threshold 5000 + +################################ REDIS CLUSTER ############################### + +# Normal Redis instances can't be part of a Redis Cluster; only nodes that are +# started as cluster nodes can. In order to start a Redis instance as a +# cluster node enable the cluster support uncommenting the following: +# +# cluster-enabled yes + +# Every cluster node has a cluster configuration file. This file is not +# intended to be edited by hand. It is created and updated by Redis nodes. +# Every Redis Cluster node requires a different cluster configuration file. +# Make sure that instances running in the same system do not have +# overlapping cluster configuration file names. +# +# cluster-config-file nodes-6379.conf + +# Cluster node timeout is the amount of milliseconds a node must be unreachable +# for it to be considered in failure state. +# Most other internal time limits are a multiple of the node timeout. +# +# cluster-node-timeout 15000 + +# The cluster port is the port that the cluster bus will listen for inbound connections on. When set +# to the default value, 0, it will be bound to the command port + 10000. Setting this value requires +# you to specify the cluster bus port when executing cluster meet. +# cluster-port 0 + +# A replica of a failing master will avoid to start a failover if its data +# looks too old. +# +# There is no simple way for a replica to actually have an exact measure of +# its "data age", so the following two checks are performed: +# +# 1) If there are multiple replicas able to failover, they exchange messages +# in order to try to give an advantage to the replica with the best +# replication offset (more data from the master processed). +# Replicas will try to get their rank by offset, and apply to the start +# of the failover a delay proportional to their rank. +# +# 2) Every single replica computes the time of the last interaction with +# its master. This can be the last ping or command received (if the master +# is still in the "connected" state), or the time that elapsed since the +# disconnection with the master (if the replication link is currently down). +# If the last interaction is too old, the replica will not try to failover +# at all. +# +# The point "2" can be tuned by user. Specifically a replica will not perform +# the failover if, since the last interaction with the master, the time +# elapsed is greater than: +# +# (node-timeout * cluster-replica-validity-factor) + repl-ping-replica-period +# +# So for example if node-timeout is 30 seconds, and the cluster-replica-validity-factor +# is 10, and assuming a default repl-ping-replica-period of 10 seconds, the +# replica will not try to failover if it was not able to talk with the master +# for longer than 310 seconds. +# +# A large cluster-replica-validity-factor may allow replicas with too old data to failover +# a master, while a too small value may prevent the cluster from being able to +# elect a replica at all. +# +# For maximum availability, it is possible to set the cluster-replica-validity-factor +# to a value of 0, which means, that replicas will always try to failover the +# master regardless of the last time they interacted with the master. +# (However they'll always try to apply a delay proportional to their +# offset rank). +# +# Zero is the only value able to guarantee that when all the partitions heal +# the cluster will always be able to continue. +# +# cluster-replica-validity-factor 10 + +# Cluster replicas are able to migrate to orphaned masters, that are masters +# that are left without working replicas. This improves the cluster ability +# to resist to failures as otherwise an orphaned master can't be failed over +# in case of failure if it has no working replicas. +# +# Replicas migrate to orphaned masters only if there are still at least a +# given number of other working replicas for their old master. This number +# is the "migration barrier". A migration barrier of 1 means that a replica +# will migrate only if there is at least 1 other working replica for its master +# and so forth. It usually reflects the number of replicas you want for every +# master in your cluster. +# +# Default is 1 (replicas migrate only if their masters remain with at least +# one replica). To disable migration just set it to a very large value or +# set cluster-allow-replica-migration to 'no'. +# A value of 0 can be set but is useful only for debugging and dangerous +# in production. +# +# cluster-migration-barrier 1 + +# Turning off this option allows to use less automatic cluster configuration. +# It both disables migration to orphaned masters and migration from masters +# that became empty. +# +# Default is 'yes' (allow automatic migrations). +# +# cluster-allow-replica-migration yes + +# By default Redis Cluster nodes stop accepting queries if they detect there +# is at least a hash slot uncovered (no available node is serving it). +# This way if the cluster is partially down (for example a range of hash slots +# are no longer covered) all the cluster becomes, eventually, unavailable. +# It automatically returns available as soon as all the slots are covered again. +# +# However sometimes you want the subset of the cluster which is working, +# to continue to accept queries for the part of the key space that is still +# covered. In order to do so, just set the cluster-require-full-coverage +# option to no. +# +# cluster-require-full-coverage yes + +# This option, when set to yes, prevents replicas from trying to failover its +# master during master failures. However the replica can still perform a +# manual failover, if forced to do so. +# +# This is useful in different scenarios, especially in the case of multiple +# data center operations, where we want one side to never be promoted if not +# in the case of a total DC failure. +# +# cluster-replica-no-failover no + +# This option, when set to yes, allows nodes to serve read traffic while the +# cluster is in a down state, as long as it believes it owns the slots. +# +# This is useful for two cases. The first case is for when an application +# doesn't require consistency of data during node failures or network partitions. +# One example of this is a cache, where as long as the node has the data it +# should be able to serve it. +# +# The second use case is for configurations that don't meet the recommended +# three shards but want to enable cluster mode and scale later. A +# master outage in a 1 or 2 shard configuration causes a read/write outage to the +# entire cluster without this option set, with it set there is only a write outage. +# Without a quorum of masters, slot ownership will not change automatically. +# +# cluster-allow-reads-when-down no + +# This option, when set to yes, allows nodes to serve pubsub shard traffic while +# the cluster is in a down state, as long as it believes it owns the slots. +# +# This is useful if the application would like to use the pubsub feature even when +# the cluster global stable state is not OK. If the application wants to make sure only +# one shard is serving a given channel, this feature should be kept as yes. +# +# cluster-allow-pubsubshard-when-down yes + +# Cluster link send buffer limit is the limit on the memory usage of an individual +# cluster bus link's send buffer in bytes. Cluster links would be freed if they exceed +# this limit. This is to primarily prevent send buffers from growing unbounded on links +# toward slow peers (E.g. PubSub messages being piled up). +# This limit is disabled by default. Enable this limit when 'mem_cluster_links' INFO field +# and/or 'send-buffer-allocated' entries in the 'CLUSTER LINKS` command output continuously increase. +# Minimum limit of 1gb is recommended so that cluster link buffer can fit in at least a single +# PubSub message by default. (client-query-buffer-limit default value is 1gb) +# +# cluster-link-sendbuf-limit 0 + +# Clusters can configure their announced hostname using this config. This is a common use case for +# applications that need to use TLS Server Name Indication (SNI) or dealing with DNS based +# routing. By default this value is only shown as additional metadata in the CLUSTER SLOTS +# command, but can be changed using 'cluster-preferred-endpoint-type' config. This value is +# communicated along the clusterbus to all nodes, setting it to an empty string will remove +# the hostname and also propagate the removal. +# +# cluster-announce-hostname "" + +# Clusters can configure an optional nodename to be used in addition to the node ID for +# debugging and admin information. This name is broadcasted between nodes, so will be used +# in addition to the node ID when reporting cross node events such as node failures. +# cluster-announce-human-nodename "" + +# Clusters can advertise how clients should connect to them using either their IP address, +# a user defined hostname, or by declaring they have no endpoint. Which endpoint is +# shown as the preferred endpoint is set by using the cluster-preferred-endpoint-type +# config with values 'ip', 'hostname', or 'unknown-endpoint'. This value controls how +# the endpoint returned for MOVED/ASKING requests as well as the first field of CLUSTER SLOTS. +# If the preferred endpoint type is set to hostname, but no announced hostname is set, a '?' +# will be returned instead. +# +# When a cluster advertises itself as having an unknown endpoint, it's indicating that +# the server doesn't know how clients can reach the cluster. This can happen in certain +# networking situations where there are multiple possible routes to the node, and the +# server doesn't know which one the client took. In this case, the server is expecting +# the client to reach out on the same endpoint it used for making the last request, but use +# the port provided in the response. +# +# cluster-preferred-endpoint-type ip + +# In order to setup your cluster make sure to read the documentation +# available at https://redis.io web site. + +########################## CLUSTER DOCKER/NAT support ######################## + +# In certain deployments, Redis Cluster nodes address discovery fails, because +# addresses are NAT-ted or because ports are forwarded (the typical case is +# Docker and other containers). +# +# In order to make Redis Cluster working in such environments, a static +# configuration where each node knows its public address is needed. The +# following four options are used for this scope, and are: +# +# * cluster-announce-ip +# * cluster-announce-port +# * cluster-announce-tls-port +# * cluster-announce-bus-port +# +# Each instructs the node about its address, client ports (for connections +# without and with TLS) and cluster message bus port. The information is then +# published in the header of the bus packets so that other nodes will be able to +# correctly map the address of the node publishing the information. +# +# If tls-cluster is set to yes and cluster-announce-tls-port is omitted or set +# to zero, then cluster-announce-port refers to the TLS port. Note also that +# cluster-announce-tls-port has no effect if tls-cluster is set to no. +# +# If the above options are not used, the normal Redis Cluster auto-detection +# will be used instead. +# +# Note that when remapped, the bus port may not be at the fixed offset of +# clients port + 10000, so you can specify any port and bus-port depending +# on how they get remapped. If the bus-port is not set, a fixed offset of +# 10000 will be used as usual. +# +# Example: +# +# cluster-announce-ip 10.1.1.5 +# cluster-announce-tls-port 6379 +# cluster-announce-port 0 +# cluster-announce-bus-port 6380 + +################################## SLOW LOG ################################### + +# The Redis Slow Log is a system to log queries that exceeded a specified +# execution time. The execution time does not include the I/O operations +# like talking with the client, sending the reply and so forth, +# but just the time needed to actually execute the command (this is the only +# stage of command execution where the thread is blocked and can not serve +# other requests in the meantime). +# +# You can configure the slow log with two parameters: one tells Redis +# what is the execution time, in microseconds, to exceed in order for the +# command to get logged, and the other parameter is the length of the +# slow log. When a new command is logged the oldest one is removed from the +# queue of logged commands. + +# The following time is expressed in microseconds, so 1000000 is equivalent +# to one second. Note that a negative number disables the slow log, while +# a value of zero forces the logging of every command. +slowlog-log-slower-than 10000 + +# There is no limit to this length. Just be aware that it will consume memory. +# You can reclaim memory used by the slow log with SLOWLOG RESET. +slowlog-max-len 128 + +################################ LATENCY MONITOR ############################## + +# The Redis latency monitoring subsystem samples different operations +# at runtime in order to collect data related to possible sources of +# latency of a Redis instance. +# +# Via the LATENCY command this information is available to the user that can +# print graphs and obtain reports. +# +# The system only logs operations that were performed in a time equal or +# greater than the amount of milliseconds specified via the +# latency-monitor-threshold configuration directive. When its value is set +# to zero, the latency monitor is turned off. +# +# By default latency monitoring is disabled since it is mostly not needed +# if you don't have latency issues, and collecting data has a performance +# impact, that while very small, can be measured under big load. Latency +# monitoring can easily be enabled at runtime using the command +# "CONFIG SET latency-monitor-threshold " if needed. +latency-monitor-threshold 0 + +################################ LATENCY TRACKING ############################## + +# The Redis extended latency monitoring tracks the per command latencies and enables +# exporting the percentile distribution via the INFO latencystats command, +# and cumulative latency distributions (histograms) via the LATENCY command. +# +# By default, the extended latency monitoring is enabled since the overhead +# of keeping track of the command latency is very small. +# latency-tracking yes + +# By default the exported latency percentiles via the INFO latencystats command +# are the p50, p99, and p999. +# latency-tracking-info-percentiles 50 99 99.9 + +############################# EVENT NOTIFICATION ############################## + +# Redis can notify Pub/Sub clients about events happening in the key space. +# This feature is documented at https://redis.io/topics/notifications +# +# For instance if keyspace events notification is enabled, and a client +# performs a DEL operation on key "foo" stored in the Database 0, two +# messages will be published via Pub/Sub: +# +# PUBLISH __keyspace@0__:foo del +# PUBLISH __keyevent@0__:del foo +# +# It is possible to select the events that Redis will notify among a set +# of classes. Every class is identified by a single character: +# +# K Keyspace events, published with __keyspace@__ prefix. +# E Keyevent events, published with __keyevent@__ prefix. +# g Generic commands (non-type specific) like DEL, EXPIRE, RENAME, ... +# $ String commands +# l List commands +# s Set commands +# h Hash commands +# z Sorted set commands +# x Expired events (events generated every time a key expires) +# e Evicted events (events generated when a key is evicted for maxmemory) +# n New key events (Note: not included in the 'A' class) +# t Stream commands +# d Module key type events +# m Key-miss events (Note: It is not included in the 'A' class) +# A Alias for g$lshzxetd, so that the "AKE" string means all the events +# (Except key-miss events which are excluded from 'A' due to their +# unique nature). +# +# The "notify-keyspace-events" takes as argument a string that is composed +# of zero or multiple characters. The empty string means that notifications +# are disabled. +# +# Example: to enable list and generic events, from the point of view of the +# event name, use: +# +# notify-keyspace-events Elg +# +# Example 2: to get the stream of the expired keys subscribing to channel +# name __keyevent@0__:expired use: +# +# notify-keyspace-events Ex +# +# By default all notifications are disabled because most users don't need +# this feature and the feature has some overhead. Note that if you don't +# specify at least one of K or E, no events will be delivered. +notify-keyspace-events "" + +############################### ADVANCED CONFIG ############################### + +# Hashes are encoded using a memory efficient data structure when they have a +# small number of entries, and the biggest entry does not exceed a given +# threshold. These thresholds can be configured using the following directives. +hash-max-listpack-entries 512 +hash-max-listpack-value 64 + +# Lists are also encoded in a special way to save a lot of space. +# The number of entries allowed per internal list node can be specified +# as a fixed maximum size or a maximum number of elements. +# For a fixed maximum size, use -5 through -1, meaning: +# -5: max size: 64 Kb <-- not recommended for normal workloads +# -4: max size: 32 Kb <-- not recommended +# -3: max size: 16 Kb <-- probably not recommended +# -2: max size: 8 Kb <-- good +# -1: max size: 4 Kb <-- good +# Positive numbers mean store up to _exactly_ that number of elements +# per list node. +# The highest performing option is usually -2 (8 Kb size) or -1 (4 Kb size), +# but if your use case is unique, adjust the settings as necessary. +list-max-listpack-size -2 + +# Lists may also be compressed. +# Compress depth is the number of quicklist ziplist nodes from *each* side of +# the list to *exclude* from compression. The head and tail of the list +# are always uncompressed for fast push/pop operations. Settings are: +# 0: disable all list compression +# 1: depth 1 means "don't start compressing until after 1 node into the list, +# going from either the head or tail" +# So: [head]->node->node->...->node->[tail] +# [head], [tail] will always be uncompressed; inner nodes will compress. +# 2: [head]->[next]->node->node->...->node->[prev]->[tail] +# 2 here means: don't compress head or head->next or tail->prev or tail, +# but compress all nodes between them. +# 3: [head]->[next]->[next]->node->node->...->node->[prev]->[prev]->[tail] +# etc. +list-compress-depth 0 + +# Sets have a special encoding when a set is composed +# of just strings that happen to be integers in radix 10 in the range +# of 64 bit signed integers. +# The following configuration setting sets the limit in the size of the +# set in order to use this special memory saving encoding. +set-max-intset-entries 512 + +# Sets containing non-integer values are also encoded using a memory efficient +# data structure when they have a small number of entries, and the biggest entry +# does not exceed a given threshold. These thresholds can be configured using +# the following directives. +set-max-listpack-entries 128 +set-max-listpack-value 64 + +# Similarly to hashes and lists, sorted sets are also specially encoded in +# order to save a lot of space. This encoding is only used when the length and +# elements of a sorted set are below the following limits: +zset-max-listpack-entries 128 +zset-max-listpack-value 64 + +# HyperLogLog sparse representation bytes limit. The limit includes the +# 16 bytes header. When a HyperLogLog using the sparse representation crosses +# this limit, it is converted into the dense representation. +# +# A value greater than 16000 is totally useless, since at that point the +# dense representation is more memory efficient. +# +# The suggested value is ~ 3000 in order to have the benefits of +# the space efficient encoding without slowing down too much PFADD, +# which is O(N) with the sparse encoding. The value can be raised to +# ~ 10000 when CPU is not a concern, but space is, and the data set is +# composed of many HyperLogLogs with cardinality in the 0 - 15000 range. +hll-sparse-max-bytes 3000 + +# Streams macro node max size / items. The stream data structure is a radix +# tree of big nodes that encode multiple items inside. Using this configuration +# it is possible to configure how big a single node can be in bytes, and the +# maximum number of items it may contain before switching to a new node when +# appending new stream entries. If any of the following settings are set to +# zero, the limit is ignored, so for instance it is possible to set just a +# max entries limit by setting max-bytes to 0 and max-entries to the desired +# value. +stream-node-max-bytes 4096 +stream-node-max-entries 100 + +# Active rehashing uses 1 millisecond every 100 milliseconds of CPU time in +# order to help rehashing the main Redis hash table (the one mapping top-level +# keys to values). The hash table implementation Redis uses (see dict.c) +# performs a lazy rehashing: the more operation you run into a hash table +# that is rehashing, the more rehashing "steps" are performed, so if the +# server is idle the rehashing is never complete and some more memory is used +# by the hash table. +# +# The default is to use this millisecond 10 times every second in order to +# actively rehash the main dictionaries, freeing memory when possible. +# +# If unsure: +# use "activerehashing no" if you have hard latency requirements and it is +# not a good thing in your environment that Redis can reply from time to time +# to queries with 2 milliseconds delay. +# +# use "activerehashing yes" if you don't have such hard requirements but +# want to free memory asap when possible. +activerehashing yes + +# The client output buffer limits can be used to force disconnection of clients +# that are not reading data from the server fast enough for some reason (a +# common reason is that a Pub/Sub client can't consume messages as fast as the +# publisher can produce them). +# +# The limit can be set differently for the three different classes of clients: +# +# normal -> normal clients including MONITOR clients +# replica -> replica clients +# pubsub -> clients subscribed to at least one pubsub channel or pattern +# +# The syntax of every client-output-buffer-limit directive is the following: +# +# client-output-buffer-limit +# +# A client is immediately disconnected once the hard limit is reached, or if +# the soft limit is reached and remains reached for the specified number of +# seconds (continuously). +# So for instance if the hard limit is 32 megabytes and the soft limit is +# 16 megabytes / 10 seconds, the client will get disconnected immediately +# if the size of the output buffers reach 32 megabytes, but will also get +# disconnected if the client reaches 16 megabytes and continuously overcomes +# the limit for 10 seconds. +# +# By default normal clients are not limited because they don't receive data +# without asking (in a push way), but just after a request, so only +# asynchronous clients may create a scenario where data is requested faster +# than it can read. +# +# Instead there is a default limit for pubsub and replica clients, since +# subscribers and replicas receive data in a push fashion. +# +# Note that it doesn't make sense to set the replica clients output buffer +# limit lower than the repl-backlog-size config (partial sync will succeed +# and then replica will get disconnected). +# Such a configuration is ignored (the size of repl-backlog-size will be used). +# This doesn't have memory consumption implications since the replica client +# will share the backlog buffers memory. +# +# Both the hard or the soft limit can be disabled by setting them to zero. +client-output-buffer-limit normal 0 0 0 +client-output-buffer-limit replica 256mb 64mb 60 +client-output-buffer-limit pubsub 32mb 8mb 60 + +# Client query buffers accumulate new commands. They are limited to a fixed +# amount by default in order to avoid that a protocol desynchronization (for +# instance due to a bug in the client) will lead to unbound memory usage in +# the query buffer. However you can configure it here if you have very special +# needs, such us huge multi/exec requests or alike. +# +# client-query-buffer-limit 1gb + +# In some scenarios client connections can hog up memory leading to OOM +# errors or data eviction. To avoid this we can cap the accumulated memory +# used by all client connections (all pubsub and normal clients). Once we +# reach that limit connections will be dropped by the server freeing up +# memory. The server will attempt to drop the connections using the most +# memory first. We call this mechanism "client eviction". +# +# Client eviction is configured using the maxmemory-clients setting as follows: +# 0 - client eviction is disabled (default) +# +# A memory value can be used for the client eviction threshold, +# for example: +# maxmemory-clients 1g +# +# A percentage value (between 1% and 100%) means the client eviction threshold +# is based on a percentage of the maxmemory setting. For example to set client +# eviction at 5% of maxmemory: +# maxmemory-clients 5% + +# In the Redis protocol, bulk requests, that are, elements representing single +# strings, are normally limited to 512 mb. However you can change this limit +# here, but must be 1mb or greater +# +# proto-max-bulk-len 512mb + +# Redis calls an internal function to perform many background tasks, like +# closing connections of clients in timeout, purging expired keys that are +# never requested, and so forth. +# +# Not all tasks are performed with the same frequency, but Redis checks for +# tasks to perform according to the specified "hz" value. +# +# By default "hz" is set to 10. Raising the value will use more CPU when +# Redis is idle, but at the same time will make Redis more responsive when +# there are many keys expiring at the same time, and timeouts may be +# handled with more precision. +# +# The range is between 1 and 500, however a value over 100 is usually not +# a good idea. Most users should use the default of 10 and raise this up to +# 100 only in environments where very low latency is required. +hz 10 + +# Normally it is useful to have an HZ value which is proportional to the +# number of clients connected. This is useful in order, for instance, to +# avoid too many clients are processed for each background task invocation +# in order to avoid latency spikes. +# +# Since the default HZ value by default is conservatively set to 10, Redis +# offers, and enables by default, the ability to use an adaptive HZ value +# which will temporarily raise when there are many connected clients. +# +# When dynamic HZ is enabled, the actual configured HZ will be used +# as a baseline, but multiples of the configured HZ value will be actually +# used as needed once more clients are connected. In this way an idle +# instance will use very little CPU time while a busy instance will be +# more responsive. +dynamic-hz yes + +# When a child rewrites the AOF file, if the following option is enabled +# the file will be fsync-ed every 4 MB of data generated. This is useful +# in order to commit the file to the disk more incrementally and avoid +# big latency spikes. +aof-rewrite-incremental-fsync yes + +# When redis saves RDB file, if the following option is enabled +# the file will be fsync-ed every 4 MB of data generated. This is useful +# in order to commit the file to the disk more incrementally and avoid +# big latency spikes. +rdb-save-incremental-fsync yes + +# Redis LFU eviction (see maxmemory setting) can be tuned. However it is a good +# idea to start with the default settings and only change them after investigating +# how to improve the performances and how the keys LFU change over time, which +# is possible to inspect via the OBJECT FREQ command. +# +# There are two tunable parameters in the Redis LFU implementation: the +# counter logarithm factor and the counter decay time. It is important to +# understand what the two parameters mean before changing them. +# +# The LFU counter is just 8 bits per key, it's maximum value is 255, so Redis +# uses a probabilistic increment with logarithmic behavior. Given the value +# of the old counter, when a key is accessed, the counter is incremented in +# this way: +# +# 1. A random number R between 0 and 1 is extracted. +# 2. A probability P is calculated as 1/(old_value*lfu_log_factor+1). +# 3. The counter is incremented only if R < P. +# +# The default lfu-log-factor is 10. This is a table of how the frequency +# counter changes with a different number of accesses with different +# logarithmic factors: +# +# +--------+------------+------------+------------+------------+------------+ +# | factor | 100 hits | 1000 hits | 100K hits | 1M hits | 10M hits | +# +--------+------------+------------+------------+------------+------------+ +# | 0 | 104 | 255 | 255 | 255 | 255 | +# +--------+------------+------------+------------+------------+------------+ +# | 1 | 18 | 49 | 255 | 255 | 255 | +# +--------+------------+------------+------------+------------+------------+ +# | 10 | 10 | 18 | 142 | 255 | 255 | +# +--------+------------+------------+------------+------------+------------+ +# | 100 | 8 | 11 | 49 | 143 | 255 | +# +--------+------------+------------+------------+------------+------------+ +# +# NOTE: The above table was obtained by running the following commands: +# +# redis-benchmark -n 1000000 incr foo +# redis-cli object freq foo +# +# NOTE 2: The counter initial value is 5 in order to give new objects a chance +# to accumulate hits. +# +# The counter decay time is the time, in minutes, that must elapse in order +# for the key counter to be decremented. +# +# The default value for the lfu-decay-time is 1. A special value of 0 means we +# will never decay the counter. +# +# lfu-log-factor 10 +# lfu-decay-time 1 + +########################### ACTIVE DEFRAGMENTATION ####################### +# +# What is active defragmentation? +# ------------------------------- +# +# Active (online) defragmentation allows a Redis server to compact the +# spaces left between small allocations and deallocations of data in memory, +# thus allowing to reclaim back memory. +# +# Fragmentation is a natural process that happens with every allocator (but +# less so with Jemalloc, fortunately) and certain workloads. Normally a server +# restart is needed in order to lower the fragmentation, or at least to flush +# away all the data and create it again. However thanks to this feature +# implemented by Oran Agra for Redis 4.0 this process can happen at runtime +# in a "hot" way, while the server is running. +# +# Basically when the fragmentation is over a certain level (see the +# configuration options below) Redis will start to create new copies of the +# values in contiguous memory regions by exploiting certain specific Jemalloc +# features (in order to understand if an allocation is causing fragmentation +# and to allocate it in a better place), and at the same time, will release the +# old copies of the data. This process, repeated incrementally for all the keys +# will cause the fragmentation to drop back to normal values. +# +# Important things to understand: +# +# 1. This feature is disabled by default, and only works if you compiled Redis +# to use the copy of Jemalloc we ship with the source code of Redis. +# This is the default with Linux builds. +# +# 2. You never need to enable this feature if you don't have fragmentation +# issues. +# +# 3. Once you experience fragmentation, you can enable this feature when +# needed with the command "CONFIG SET activedefrag yes". +# +# The configuration parameters are able to fine tune the behavior of the +# defragmentation process. If you are not sure about what they mean it is +# a good idea to leave the defaults untouched. + +# Active defragmentation is disabled by default +# activedefrag no + +# Minimum amount of fragmentation waste to start active defrag +# active-defrag-ignore-bytes 100mb + +# Minimum percentage of fragmentation to start active defrag +# active-defrag-threshold-lower 10 + +# Maximum percentage of fragmentation at which we use maximum effort +# active-defrag-threshold-upper 100 + +# Minimal effort for defrag in CPU percentage, to be used when the lower +# threshold is reached +# active-defrag-cycle-min 1 + +# Maximal effort for defrag in CPU percentage, to be used when the upper +# threshold is reached +# active-defrag-cycle-max 25 + +# Maximum number of set/hash/zset/list fields that will be processed from +# the main dictionary scan +# active-defrag-max-scan-fields 1000 + +# Jemalloc background thread for purging will be enabled by default +jemalloc-bg-thread yes + +# It is possible to pin different threads and processes of Redis to specific +# CPUs in your system, in order to maximize the performances of the server. +# This is useful both in order to pin different Redis threads in different +# CPUs, but also in order to make sure that multiple Redis instances running +# in the same host will be pinned to different CPUs. +# +# Normally you can do this using the "taskset" command, however it is also +# possible to this via Redis configuration directly, both in Linux and FreeBSD. +# +# You can pin the server/IO threads, bio threads, aof rewrite child process, and +# the bgsave child process. The syntax to specify the cpu list is the same as +# the taskset command: +# +# Set redis server/io threads to cpu affinity 0,2,4,6: +# server_cpulist 0-7:2 +# +# Set bio threads to cpu affinity 1,3: +# bio_cpulist 1,3 +# +# Set aof rewrite child process to cpu affinity 8,9,10,11: +# aof_rewrite_cpulist 8-11 +# +# Set bgsave child process to cpu affinity 1,10,11 +# bgsave_cpulist 1,10-11 + +# In some cases redis will emit warnings and even refuse to start if it detects +# that the system is in bad state, it is possible to suppress these warnings +# by setting the following config which takes a space delimited list of warnings +# to suppress +# +# ignore-warnings ARM64-COW-BUG diff --git a/docker/run.md b/docker/run.md new file mode 100644 index 0000000..1ee1fe5 --- /dev/null +++ b/docker/run.md @@ -0,0 +1,16 @@ + +# Docker Compose 安装中间件 MySQL、Redis、Minio、Xxl-Job + +## 安装 + +```bash +docker-compose -f ./docker-compose.yml -p youlai-boot up -d +``` + +- p youlai-boot 指定命名空间,避免与其他容器冲突,这里方便管理,统一管理和卸载 + +## 卸载 +```bash +docker-compose -f ./docker-compose.yml -p youlai-boot down +``` + diff --git a/docker/xxljob/README.md b/docker/xxljob/README.md new file mode 100644 index 0000000..e69de29 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..3330605 --- /dev/null +++ b/pom.xml @@ -0,0 +1,319 @@ + + + 4.0.0 + + com.youlai + stray-animals + 4.3.0 + 基于 Java 17 + SpringBoot 4 + Spring Security 构建的权限管理系统。 + + + org.springframework.boot + spring-boot-starter-parent + 4.0.1 + + + + + 17 + 17 + + 5.8.41 + + 9.1.0 + 1.2.24 + + 3.5.15 + 4.3.1 + + 4.5.0 + + 1.6.3 + 0.2.0 + + 3.2.0 + + 1.3.0 + + + 8.5.10 + 4.8.1 + + 3.16.3 + + + 4.1.0 + + + 3.5.6 + 2.3 + + + 2.7.0 + + + 4.7.6 + 2.2.1 + + 2.9.3 + + + 2.14.5 + + 4.8.1.B + + + + + + + org.projectlombok + lombok + + provided + + + + cn.hutool + hutool-all + ${hutool.version} + + + + + com.alibaba + transmittable-thread-local + ${transmittable-thread-local.version} + + + + + org.projectlombok + lombok-mapstruct-binding + ${lombok-mapstruct-binding.version} + provided + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + org.springframework.boot + spring-boot-starter-cache + + + + + org.springframework.boot + spring-boot-starter-aspectj + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-mail + + + + com.mysql + mysql-connector-j + ${mysql-connector-j.version} + runtime + + + + com.alibaba + druid-spring-boot-starter + ${druid.version} + + + + + com.baomidou + mybatis-plus-spring-boot4-starter + ${mybatis-plus.version} + + + com.baomidou + mybatis-plus-jsqlparser + ${mybatis-plus.version} + + + + + com.github.xiaoymin + knife4j-openapi3-jakarta-spring-boot-starter + ${knife4j.version} + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.9 + + + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + + + com.xuxueli + xxl-job-core + ${xxl-job.version} + + + + + cn.idev.excel + fastexcel + ${fastexcel.version} + + + + + io.minio + minio + ${minio.version} + + + + + com.aliyun.oss + aliyun-sdk-oss + ${aliyun-sdk-oss.version} + + + + + org.redisson + redisson-spring-boot-starter + ${redisson.version} + + + + + com.baomidou + mybatis-plus-generator + ${mybatis-plus-generator.version} + + + + + org.apache.velocity + velocity-engine-core + ${velocity.version} + + + + + org.lionsoul + ip2region + ${ip2region.version} + + + + com.aliyun + aliyun-java-sdk-core + ${aliyun.java.sdk.core.version} + + + + com.aliyun + aliyun-java-sdk-dysmsapi + ${aliyun.java.sdk.dysmsapi.version} + + + + + com.github.ben-manes.caffeine + caffeine + ${caffeine.version} + + + + + com.github.binarywang + weixin-java-miniapp + ${weixin-java-miniapp.version} + + + + + + + + + ${project.artifactId} + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + **/application.yml + **/application-dev.yml + **/application-prod.yml + + + + + + + diff --git a/sql/mysql/youlai_admin.sql b/sql/mysql/youlai_admin.sql new file mode 100644 index 0000000..2bf4209 --- /dev/null +++ b/sql/mysql/youlai_admin.sql @@ -0,0 +1,586 @@ + +# YouLai_Admin 数据库(MySQL 5.7 ~ MySQL 8.x) +# Copyright (c) 2021-present, youlai.tech + + +-- ---------------------------- +-- 1. 创建数据库 +-- ---------------------------- +CREATE DATABASE IF NOT EXISTS youlai_admin CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + + +-- ---------------------------- +-- 2. 创建表 && 数据初始化 +-- ---------------------------- +USE youlai_admin; + +SET NAMES utf8mb4; # 设置字符集 +SET FOREIGN_KEY_CHECKS = 0; # 关闭外键检查,加快导入速度 + +-- ---------------------------- +-- Table structure for sys_dept +-- ---------------------------- +DROP TABLE IF EXISTS `sys_dept`; +CREATE TABLE `sys_dept` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `name` varchar(100) NOT NULL COMMENT '部门名称', + `code` varchar(100) NOT NULL COMMENT '部门编号', + `parent_id` bigint DEFAULT 0 COMMENT '父节点id', + `tree_path` varchar(255) NOT NULL COMMENT '父节点id路径', + `sort` smallint DEFAULT 0 COMMENT '显示顺序', + `status` tinyint DEFAULT 1 COMMENT '状态(1-正常 0-禁用)', + `create_by` bigint NULL COMMENT '创建人ID', + `create_time` datetime NULL COMMENT '创建时间', + `update_by` bigint NULL COMMENT '修改人ID', + `update_time` datetime NULL COMMENT '更新时间', + `is_deleted` tinyint DEFAULT 0 COMMENT '逻辑删除标识(1-已删除 0-未删除)', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_code`(`code` ASC) USING BTREE COMMENT '部门编号唯一索引' +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '部门管理表'; + +-- ---------------------------- +-- Records of sys_dept +-- ---------------------------- +INSERT INTO `sys_dept` VALUES (1, '有来技术', 'YOULAI', 0, '0', 1, 1, 1, NULL, 1, now(), 0); +INSERT INTO `sys_dept` VALUES (2, '研发部门', 'RD001', 1, '0,1', 1, 1, 2, NULL, 2, now(), 0); +INSERT INTO `sys_dept` VALUES (3, '测试部门', 'QA001', 1, '0,1', 1, 1, 2, NULL, 2, now(), 0); + +-- ---------------------------- +-- Table structure for sys_dict +-- ---------------------------- +DROP TABLE IF EXISTS `sys_dict`; +CREATE TABLE `sys_dict` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键 ', + `dict_code` varchar(50) COMMENT '类型编码', + `name` varchar(50) COMMENT '类型名称', + `status` tinyint(1) DEFAULT '0' COMMENT '状态(0:正常;1:禁用)', + `remark` varchar(255) COMMENT '备注', + `create_time` datetime COMMENT '创建时间', + `create_by` bigint COMMENT '创建人ID', + `update_time` datetime COMMENT '更新时间', + `update_by` bigint COMMENT '修改人ID', + `is_deleted` tinyint DEFAULT '0' COMMENT '是否删除(1-删除,0-未删除)', + PRIMARY KEY (`id`) USING BTREE, + KEY `idx_dict_code` (`dict_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据字典类型表'; +-- ---------------------------- +-- Records of sys_dict +-- ---------------------------- +INSERT INTO `sys_dict` VALUES (1, 'gender', '性别', 1, NULL, now() , 1,now(), 1,0); +INSERT INTO `sys_dict` VALUES (2, 'notice_type', '通知类型', 1, NULL, now(), 1,now(), 1,0); +INSERT INTO `sys_dict` VALUES (3, 'notice_level', '通知级别', 1, NULL, now(), 1,now(), 1,0); + + +-- ---------------------------- +-- Table structure for sys_dict_item +-- ---------------------------- +DROP TABLE IF EXISTS `sys_dict_item`; +CREATE TABLE `sys_dict_item` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `dict_code` varchar(50) COMMENT '关联字典编码,与sys_dict表中的dict_code对应', + `value` varchar(50) COMMENT '字典项值', + `label` varchar(100) COMMENT '字典项标签', + `tag_type` varchar(50) COMMENT '标签类型,用于前端样式展示(如success、warning等)', + `status` tinyint DEFAULT '0' COMMENT '状态(1-正常,0-禁用)', + `sort` int DEFAULT '0' COMMENT '排序', + `remark` varchar(255) COMMENT '备注', + `create_time` datetime COMMENT '创建时间', + `create_by` bigint COMMENT '创建人ID', + `update_time` datetime COMMENT '更新时间', + `update_by` bigint COMMENT '修改人ID', + PRIMARY KEY (`id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据字典项表'; + +-- ---------------------------- +-- Records of sys_dict_item +-- ---------------------------- +INSERT INTO `sys_dict_item` VALUES (1, 'gender', '1', '男', 'primary', 1, 1, NULL, now(), 1,now(),1); +INSERT INTO `sys_dict_item` VALUES (2, 'gender', '2', '女', 'danger', 1, 2, NULL, now(), 1,now(),1); +INSERT INTO `sys_dict_item` VALUES (3, 'gender', '0', '保密', 'info', 1, 3, NULL, now(), 1,now(),1); +INSERT INTO `sys_dict_item` VALUES (4, 'notice_type', '1', '系统升级', 'success', 1, 1, '', now(), 1,now(),1); +INSERT INTO `sys_dict_item` VALUES (5, 'notice_type', '2', '系统维护', 'primary', 1, 2, '', now(), 1,now(),1); +INSERT INTO `sys_dict_item` VALUES (6, 'notice_type', '3', '安全警告', 'danger', 1, 3, '', now(), 1,now(),1); +INSERT INTO `sys_dict_item` VALUES (7, 'notice_type', '4', '假期通知', 'success', 1, 4, '', now(), 1,now(),1); +INSERT INTO `sys_dict_item` VALUES (8, 'notice_type', '5', '公司新闻', 'primary', 1, 5, '', now(), 1,now(),1); +INSERT INTO `sys_dict_item` VALUES (9, 'notice_type', '99', '其他', 'info', 1, 99, '', now(), 1,now(),1); +INSERT INTO `sys_dict_item` VALUES (10, 'notice_level', 'L', '低', 'info', 1, 1, '', now(), 1,now(),1); +INSERT INTO `sys_dict_item` VALUES (11, 'notice_level', 'M', '中', 'warning', 1, 2, '', now(), 1,now(),1); +INSERT INTO `sys_dict_item` VALUES (12, 'notice_level', 'H', '高', 'danger', 1, 3, '', now(), 1,now(),1); + +-- ---------------------------- +-- Table structure for sys_menu +-- ---------------------------- +DROP TABLE IF EXISTS `sys_menu`; +CREATE TABLE `sys_menu` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID', + `parent_id` bigint NOT NULL COMMENT '父菜单ID', + `tree_path` varchar(255) COMMENT '父节点ID路径', + `name` varchar(64) NOT NULL COMMENT '菜单名称', + `type` char(1) NOT NULL COMMENT '菜单类型(C-目录 M-菜单 B-按钮)', + `route_name` varchar(255) COMMENT '路由名称(Vue Router 中用于命名路由)', + `route_path` varchar(128) COMMENT '路由路径(Vue Router 中定义的 URL 路径)', + `component` varchar(128) COMMENT '组件路径(组件页面完整路径,相对于 src/views/,缺省后缀 .vue)', + `perm` varchar(128) COMMENT '【按钮】权限标识', + `always_show` tinyint DEFAULT 0 COMMENT '【目录】只有一个子路由是否始终显示(1-是 0-否)', + `keep_alive` tinyint DEFAULT 0 COMMENT '【菜单】是否开启页面缓存(1-是 0-否)', + `visible` tinyint(1) DEFAULT 1 COMMENT '显示状态(1-显示 0-隐藏)', + `sort` int DEFAULT 0 COMMENT '排序', + `icon` varchar(64) COMMENT '菜单图标', + `redirect` varchar(128) COMMENT '跳转路径', + `create_time` datetime NULL COMMENT '创建时间', + `update_time` datetime NULL COMMENT '更新时间', + `params` json NULL COMMENT '路由参数', + PRIMARY KEY (`id`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '系统菜单表'; + +-- ---------------------------- +-- Records of sys_menu +-- ---------------------------- +-- 顶级目录(1-9):系统/代码生成/文档/接口文档/组件/演示/多级/路由 +INSERT INTO `sys_menu` VALUES (1, 0, '0', '系统管理', 'C', '', '/system', 'Layout', NULL, NULL, NULL, 1, 1, 'system', '/system/user', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2, 0, '0', '代码生成', 'C', '', '/codegen', 'Layout', NULL, NULL, NULL, 1, 2, 'code', '/codegen/index', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (4, 0, '0', '平台文档', 'C', '', '/doc', 'Layout', NULL, NULL, NULL, 1, 4, 'document', '', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (5, 0, '0', '接口文档', 'C', '', '/api', 'Layout', NULL, NULL, NULL, 1, 5, 'api', '', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (6, 0, '0', '组件封装', 'C', '', '/component', 'Layout', NULL, NULL, NULL, 1, 6, 'menu', '', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (7, 0, '0', '功能演示', 'C', '', '/function', 'Layout', NULL, NULL, NULL, 1, 7, 'menu', '', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (8, 0, '0', '多级菜单', 'C', NULL, '/multi-level', 'Layout', NULL, 1, NULL, 1, 8, 'cascader', '', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (9, 0, '0', '路由参数', 'C', '', '/route-param', 'Layout', NULL, NULL, NULL, 1, 9, 'el-icon-ElementPlus', '', now(), now(), NULL); + +-- 系统管理 +INSERT INTO `sys_menu` VALUES (210, 1, '0,1', '用户管理', 'M', 'User', 'user', 'system/user/index', NULL, NULL, 1, 1, 1, 'el-icon-User', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2101, 210, '0,1,210', '用户查询', 'B', NULL, '', NULL, 'sys:user:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2102, 210, '0,1,210', '用户新增', 'B', NULL, '', NULL, 'sys:user:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2103, 210, '0,1,210', '用户编辑', 'B', NULL, '', NULL, 'sys:user:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2104, 210, '0,1,210', '用户删除', 'B', NULL, '', NULL, 'sys:user:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2105, 210, '0,1,210', '重置密码', 'B', NULL, '', NULL, 'sys:user:reset-password', NULL, NULL, 1, 5, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2106, 210, '0,1,210', '用户导入', 'B', NULL, '', NULL, 'sys:user:import', NULL, NULL, 1, 6, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2107, 210, '0,1,210', '用户导出', 'B', NULL, '', NULL, 'sys:user:export', NULL, NULL, 1, 7, '', NULL, now(), now(), NULL); + +INSERT INTO `sys_menu` VALUES (220, 1, '0,1', '角色管理', 'M', 'Role', 'role', 'system/role/index', NULL, NULL, 1, 1, 2, 'role', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2201, 220, '0,1,220', '角色查询', 'B', NULL, '', NULL, 'sys:role:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2202, 220, '0,1,220', '角色新增', 'B', NULL, '', NULL, 'sys:role:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2203, 220, '0,1,220', '角色编辑', 'B', NULL, '', NULL, 'sys:role:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2204, 220, '0,1,220', '角色删除', 'B', NULL, '', NULL, 'sys:role:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2205, 220, '0,1,220', '角色分配权限', 'B', NULL, '', NULL, 'sys:role:assign', NULL, NULL, 1, 5, '', NULL, now(), now(), NULL); + +INSERT INTO `sys_menu` VALUES (230, 1, '0,1', '菜单管理', 'M', 'SysMenu', 'menu', 'system/menu/index', NULL, NULL, 1, 1, 3, 'menu', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2301, 230, '0,1,230', '菜单查询', 'B', NULL, '', NULL, 'sys:menu:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2302, 230, '0,1,230', '菜单新增', 'B', NULL, '', NULL, 'sys:menu:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2303, 230, '0,1,230', '菜单编辑', 'B', NULL, '', NULL, 'sys:menu:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2304, 230, '0,1,230', '菜单删除', 'B', NULL, '', NULL, 'sys:menu:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); + +INSERT INTO `sys_menu` VALUES (240, 1, '0,1', '部门管理', 'M', 'Dept', 'dept', 'system/dept/index', NULL, NULL, 1, 1, 4, 'tree', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2401, 240, '0,1,240', '部门查询', 'B', NULL, '', NULL, 'sys:dept:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2402, 240, '0,1,240', '部门新增', 'B', NULL, '', NULL, 'sys:dept:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2403, 240, '0,1,240', '部门编辑', 'B', NULL, '', NULL, 'sys:dept:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2404, 240, '0,1,240', '部门删除', 'B', NULL, '', NULL, 'sys:dept:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); + +INSERT INTO `sys_menu` VALUES (250, 1, '0,1', '字典管理', 'M', 'Dict', 'dict', 'system/dict/index', NULL, NULL, 1, 1, 5, 'dict', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2501, 250, '0,1,250', '字典查询', 'B', NULL, '', NULL, 'sys:dict:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2502, 250, '0,1,250', '字典新增', 'B', NULL, '', NULL, 'sys:dict:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2503, 250, '0,1,250', '字典编辑', 'B', NULL, '', NULL, 'sys:dict:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2504, 250, '0,1,250', '字典删除', 'B', NULL, '', NULL, 'sys:dict:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); + +INSERT INTO `sys_menu` VALUES (251, 1, '0,1', '字典项', 'M', 'DictItem', 'dict-item', 'system/dict/dict-item', NULL, 0, 1, 0, 6, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2511, 251, '0,1,251', '字典项查询', 'B', NULL, '', NULL, 'sys:dict-item:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2512, 251, '0,1,251', '字典项新增', 'B', NULL, '', NULL, 'sys:dict-item:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2513, 251, '0,1,251', '字典项编辑', 'B', NULL, '', NULL, 'sys:dict-item:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2514, 251, '0,1,251', '字典项删除', 'B', NULL, '', NULL, 'sys:dict-item:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); + +INSERT INTO `sys_menu` VALUES (260, 1, '0,1', '系统日志', 'M', 'Log', 'log', 'system/log/index', NULL, 0, 1, 1, 7, 'document', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2601, 260, '0,1,260', '日志查询', 'B', NULL, '', NULL, 'sys:log:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); + +INSERT INTO `sys_menu` VALUES (270, 1, '0,1', '系统配置', 'M', 'Config', 'config', 'system/config/index', NULL, 0, 1, 1, 8, 'setting', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2701, 270, '0,1,270', '系统配置查询', 'B', NULL, '', NULL, 'sys:config:list', 0, 1, 1, 1, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2702, 270, '0,1,270', '系统配置新增', 'B', NULL, '', NULL, 'sys:config:create', 0, 1, 1, 2, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2703, 270, '0,1,270', '系统配置修改', 'B', NULL, '', NULL, 'sys:config:update', 0, 1, 1, 3, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2704, 270, '0,1,270', '系统配置删除', 'B', NULL, '', NULL, 'sys:config:delete', 0, 1, 1, 4, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2705, 270, '0,1,270', '系统配置刷新', 'B', NULL, '', NULL, 'sys:config:refresh', 0, 1, 1, 5, '', NULL, now(), now(), NULL); + +INSERT INTO `sys_menu` VALUES (280, 1, '0,1', '通知公告', 'M', 'Notice', 'notice', 'system/notice/index', NULL, NULL, NULL, 1, 9, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2801, 280, '0,1,280', '通知查询', 'B', NULL, '', NULL, 'sys:notice:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2802, 280, '0,1,280', '通知新增', 'B', NULL, '', NULL, 'sys:notice:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2803, 280, '0,1,280', '通知编辑', 'B', NULL, '', NULL, 'sys:notice:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2804, 280, '0,1,280', '通知删除', 'B', NULL, '', NULL, 'sys:notice:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2805, 280, '0,1,280', '通知发布', 'B', NULL, '', NULL, 'sys:notice:publish', 0, 1, 1, 5, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2806, 280, '0,1,280', '通知撤回', 'B', NULL, '', NULL, 'sys:notice:revoke', 0, 1, 1, 6, '', NULL, now(), now(), NULL); + +-- 代码生成 +INSERT INTO `sys_menu` VALUES (310, 2, '0,2', '代码生成', 'M', 'Codegen', 'codegen', 'codegen/index', NULL, NULL, 1, 1, 1, 'code', NULL, now(), now(), NULL); + +-- 平台文档(外链通过 route_path 识别) +INSERT INTO `sys_menu` VALUES (501, 4, '0,4', '平台文档(外链)', 'M', NULL, 'https://juejin.cn/post/7228990409909108793', '', NULL, NULL, NULL, 1, 1, 'document', '', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (502, 4, '0,4', '后端文档', 'M', NULL, 'https://youlai.blog.csdn.net/article/details/145178880', '', NULL, NULL, NULL, 1, 2, 'document', '', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (503, 4, '0,4', '移动端文档', 'M', NULL, 'https://youlai.blog.csdn.net/article/details/143222890', '', NULL, NULL, NULL, 1, 3, 'document', '', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (504, 4, '0,4', '内部文档', 'M', NULL, 'internal-doc', 'demo/internal-doc', NULL, NULL, NULL, 1, 4, 'document', '', now(), now(), NULL); + +-- 接口文档 +INSERT INTO `sys_menu` VALUES (601, 5, '0,5', 'Apifox', 'M', 'Apifox', 'apifox', 'demo/api/apifox', NULL, NULL, 1, 1, 1, 'api', '', now(), now(), NULL); + +-- 组件封装 +INSERT INTO `sys_menu` VALUES (701, 6, '0,6', '富文本编辑器', 'M', 'WangEditor', 'wang-editor', 'demo/wang-editor', NULL, NULL, 1, 1, 2, '', '', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (702, 6, '0,6', '图片上传', 'M', 'Upload', 'upload', 'demo/upload', NULL, NULL, 1, 1, 3, '', '', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (703, 6, '0,6', '图标选择器', 'M', 'IconSelect', 'icon-select', 'demo/icon-select', NULL, NULL, 1, 1, 4, '', '', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (704, 6, '0,6', '字典组件', 'M', 'DictDemo', 'dict-demo', 'demo/dictionary', NULL, NULL, 1, 1, 4, '', '', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (705, 6, '0,6', '增删改查', 'M', 'Curd', 'curd', 'demo/curd/index', NULL, NULL, 1, 1, 0, '', '', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (706, 6, '0,6', '列表选择器', 'M', 'TableSelect', 'table-select', 'demo/table-select/index', NULL, NULL, 1, 1, 1, '', '', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (707, 6, '0,6', '拖拽组件', 'M', 'Drag', 'drag', 'demo/drag', NULL, NULL, NULL, 1, 5, '', '', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (708, 6, '0,6', '滚动文本', 'M', 'TextScroll', 'text-scroll', 'demo/text-scroll', NULL, NULL, NULL, 1, 6, '', '', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (709, 6, '0,6', '自适应表格操作列', 'M', 'AutoOperationColumn', 'operation-column', 'demo/auto-operation-column', NULL, NULL, 1, 1, 1, '', '', now(), now(), NULL); + +-- 功能演示 +INSERT INTO `sys_menu` VALUES (801, 7, '0,7', 'Icons', 'M', 'IconDemo', 'icon-demo', 'demo/icons', NULL, NULL, 1, 1, 2, 'el-icon-Notification', '', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (802, 7, '0,7', '字典实时同步', 'M', 'DictSync', 'dict-sync', 'demo/dict-sync', NULL, NULL, NULL, 1, 3, '', '', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (803, 7, '0,7', 'VxeTable', 'M', 'VxeTable', 'vxe-table', 'demo/vxe-table/index', NULL, NULL, 1, 1, 4, 'el-icon-MagicStick', '', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (804, 7, '0,7', 'CURD单文件', 'M', 'CurdSingle', 'curd-single', 'demo/curd-single', NULL, NULL, 1, 1, 5, 'el-icon-Reading', '', now(), now(), NULL); + +-- 多级菜单示例 +INSERT INTO `sys_menu` VALUES (910, 8, '0,8', '菜单一级', 'C', NULL, 'multi-level1', 'Layout', NULL, 1, NULL, 1, 1, '', '', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (911, 910, '0,8,910', '菜单二级', 'C', NULL, 'multi-level2', 'Layout', NULL, 0, NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (912, 911, '0,8,910,911', '菜单三级-1', 'M', NULL, 'multi-level3-1', 'demo/multi-level/children/children/level3-1', NULL, 0, 1, 1, 1, '', '', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (913, 911, '0,8,910,911', '菜单三级-2', 'M', NULL, 'multi-level3-2', 'demo/multi-level/children/children/level3-2', NULL, 0, 1, 1, 2, '', '', now(), now(), NULL); + +-- 路由参数 +INSERT INTO `sys_menu` VALUES (1001, 9, '0,9', '参数(type=1)', 'M', 'RouteParamType1', 'route-param-type1', 'demo/route-param', NULL, 0, 1, 1, 1, 'el-icon-Star', NULL, now(), now(), '{\"type\": \"1\"}'); +INSERT INTO `sys_menu` VALUES (1002, 9, '0,9', '参数(type=2)', 'M', 'RouteParamType2', 'route-param-type2', 'demo/route-param', NULL, 0, 1, 1, 2, 'el-icon-StarFilled', NULL, now(), now(), '{\"type\": \"2\"}'); + +-- ---------------------------- +-- Table structure for sys_role +-- ---------------------------- +DROP TABLE IF EXISTS `sys_role`; +CREATE TABLE `sys_role` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(64) NOT NULL COMMENT '角色名称', + `code` varchar(32) NOT NULL COMMENT '角色编码', + `sort` int NULL COMMENT '显示顺序', + `status` tinyint(1) DEFAULT 1 COMMENT '角色状态(1-正常 0-停用)', + `data_scope` tinyint NULL COMMENT '数据权限(1-所有数据 2-部门及子部门数据 3-本部门数据 4-本人数据 5-自定义部门数据)', + `create_by` bigint NULL COMMENT '创建人 ID', + `create_time` datetime NULL COMMENT '创建时间', + `update_by` bigint NULL COMMENT '更新人ID', + `update_time` datetime NULL COMMENT '更新时间', + `is_deleted` tinyint(1) DEFAULT 0 COMMENT '逻辑删除标识(0-未删除 1-已删除)', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_name`(`name` ASC) USING BTREE COMMENT '角色名称唯一索引', + UNIQUE INDEX `uk_code`(`code` ASC) USING BTREE COMMENT '角色编码唯一索引' +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '系统角色表'; + +-- ---------------------------- +-- Records of sys_role +-- ---------------------------- +INSERT INTO `sys_role` VALUES (1, '超级管理员', 'ROOT', 1, 1, 1, NULL, now(), NULL, now(), 0); +INSERT INTO `sys_role` VALUES (2, '系统管理员', 'ADMIN', 2, 1, 1, NULL, now(), NULL, NULL, 0); +INSERT INTO `sys_role` VALUES (3, '访问游客', 'GUEST', 3, 1, 3, NULL, now(), NULL, now(), 0); +INSERT INTO `sys_role` VALUES (4, '部门主管', 'DEPT_MANAGER', 4, 1, 2, NULL, now(), NULL, now(), 0); +INSERT INTO `sys_role` VALUES (5, '部门成员', 'DEPT_MEMBER', 5, 1, 3, NULL, now(), NULL, now(), 0); +INSERT INTO `sys_role` VALUES (6, '普通员工', 'EMPLOYEE', 6, 1, 4, NULL, now(), NULL, now(), 0); +INSERT INTO `sys_role` VALUES (7, '自定义权限用户', 'CUSTOM_USER', 7, 1, 5, NULL, now(), NULL, now(), 0); + +-- ---------------------------- +-- Table structure for sys_role_menu +-- ---------------------------- +DROP TABLE IF EXISTS `sys_role_menu`; +CREATE TABLE `sys_role_menu` ( + `role_id` bigint NOT NULL COMMENT '角色ID', + `menu_id` bigint NOT NULL COMMENT '菜单ID', + UNIQUE INDEX `uk_roleid_menuid`(`role_id` ASC, `menu_id` ASC) USING BTREE COMMENT '角色菜单唯一索引' +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '角色菜单关联表'; + +-- ---------------------------- +-- Table structure for sys_role_dept +-- ---------------------------- +DROP TABLE IF EXISTS `sys_role_dept`; +CREATE TABLE `sys_role_dept` ( + `role_id` bigint NOT NULL COMMENT '角色ID', + `dept_id` bigint NOT NULL COMMENT '部门ID', + UNIQUE INDEX `uk_roleid_deptid`(`role_id` ASC, `dept_id` ASC) USING BTREE COMMENT '角色部门唯一索引' +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '角色部门关联表'; + +-- ---------------------------- +-- Records of sys_role_dept +-- ---------------------------- +INSERT IGNORE INTO `sys_role_dept` VALUES (7, 1); +INSERT IGNORE INTO `sys_role_dept` VALUES (7, 2); + +-- ============================================ +-- 系统管理员角色菜单权限(role_id=2) +-- 顶级目录 +INSERT INTO `sys_role_menu` VALUES (2, 1), (2, 2), (2, 4), (2, 5), (2, 6), (2, 7), (2, 8), (2, 9); +-- 系统管理 +INSERT INTO `sys_role_menu` VALUES (2, 210), (2, 2101), (2, 2102), (2, 2103), (2, 2104), (2, 2105), (2, 2106), (2, 2107); +INSERT INTO `sys_role_menu` VALUES (2, 220), (2, 2201), (2, 2202), (2, 2203), (2, 2204), (2, 2205); +INSERT INTO `sys_role_menu` VALUES (2, 230), (2, 2301), (2, 2302), (2, 2303), (2, 2304); +INSERT INTO `sys_role_menu` VALUES (2, 240), (2, 2401), (2, 2402), (2, 2403), (2, 2404); +INSERT INTO `sys_role_menu` VALUES (2, 250), (2, 2501), (2, 2502), (2, 2503), (2, 2504); +INSERT INTO `sys_role_menu` VALUES (2, 251), (2, 2511), (2, 2512), (2, 2513), (2, 2514); +INSERT INTO `sys_role_menu` VALUES (2, 260), (2, 2601); +INSERT INTO `sys_role_menu` VALUES (2, 270), (2, 2701), (2, 2702), (2, 2703), (2, 2704), (2, 2705); +INSERT INTO `sys_role_menu` VALUES (2, 280), (2, 2801), (2, 2802), (2, 2803), (2, 2804), (2, 2805), (2, 2806); + +INSERT IGNORE INTO `sys_role_menu` VALUES (4, 1); +INSERT IGNORE INTO `sys_role_menu` VALUES (4, 210), (4, 2101), (4, 2102), (4, 2103), (4, 2104), (4, 2105), (4, 2106), (4, 2107); +INSERT IGNORE INTO `sys_role_menu` VALUES (4, 220), (4, 2201), (4, 2202), (4, 2203), (4, 2204), (4, 2205); + +INSERT IGNORE INTO `sys_role_menu` VALUES (5, 1); +INSERT IGNORE INTO `sys_role_menu` VALUES (5, 210), (5, 2101), (5, 2102), (5, 2103), (5, 2104), (5, 2105), (5, 2106), (5, 2107); +INSERT IGNORE INTO `sys_role_menu` VALUES (5, 220), (5, 2201), (5, 2202), (5, 2203), (5, 2204), (5, 2205); + +INSERT IGNORE INTO `sys_role_menu` VALUES (6, 1); +INSERT IGNORE INTO `sys_role_menu` VALUES (6, 210), (6, 2101), (6, 2102), (6, 2103), (6, 2104), (6, 2105), (6, 2106), (6, 2107); +INSERT IGNORE INTO `sys_role_menu` VALUES (6, 220), (6, 2201), (6, 2202), (6, 2203), (6, 2204), (6, 2205); + +INSERT IGNORE INTO `sys_role_menu` VALUES (7, 1); +INSERT IGNORE INTO `sys_role_menu` VALUES (7, 210), (7, 2101), (7, 2102), (7, 2103), (7, 2104), (7, 2105), (7, 2106), (7, 2107); +INSERT IGNORE INTO `sys_role_menu` VALUES (7, 220), (7, 2201), (7, 2202), (7, 2203), (7, 2204), (7, 2205); +-- 代码生成 +INSERT INTO `sys_role_menu` VALUES (2, 310); +-- 平台文档 +INSERT INTO `sys_role_menu` VALUES (2, 501), (2, 502), (2, 503), (2, 504); +-- 接口文档 +INSERT INTO `sys_role_menu` VALUES (2, 601); +-- 组件封装 +INSERT INTO `sys_role_menu` VALUES (2, 701), (2, 702), (2, 703), (2, 704), (2, 705), (2, 706), (2, 707), (2, 708), (2, 709); +-- 功能演示 / 多级菜单 +INSERT INTO `sys_role_menu` VALUES (2, 801), (2, 802), (2, 803), (2, 804), (2, 910), (2, 911), (2, 912), (2, 913); +-- 路由参数 +INSERT INTO `sys_role_menu` VALUES (2, 1001), (2, 1002); + +-- ---------------------------- +-- Table structure for sys_user +-- ---------------------------- +DROP TABLE IF EXISTS `sys_user`; +CREATE TABLE `sys_user` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `username` varchar(64) COMMENT '用户名', + `nickname` varchar(64) COMMENT '昵称', + `gender` tinyint(1) DEFAULT 1 COMMENT '性别((1-男 2-女 0-保密)', + `password` varchar(100) COMMENT '密码', + `dept_id` int COMMENT '部门ID', + `avatar` varchar(255) COMMENT '用户头像', + `mobile` varchar(20) COMMENT '联系方式', + `status` tinyint(1) DEFAULT 1 COMMENT '状态(1-正常 0-禁用)', + `email` varchar(128) COMMENT '用户邮箱', + `create_time` datetime COMMENT '创建时间', + `create_by` bigint COMMENT '创建人ID', + `update_time` datetime COMMENT '更新时间', + `update_by` bigint COMMENT '修改人ID', + `is_deleted` tinyint(1) DEFAULT 0 COMMENT '逻辑删除标识(0-未删除 1-已删除)', + PRIMARY KEY (`id`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '系统用户表'; + +-- ---------------------------- +-- Records of sys_user +-- ---------------------------- +INSERT INTO `sys_user` VALUES (1, 'root', '有来技术', 0, '$2a$10$xVWsNOhHrCxh5UbpCE7/HuJ.PAOKcYAqRxD2CO2nVnJS.IAXkr5aq', NULL, 'https://foruda.gitee.com/images/1723603502796844527/03cdca2a_716974.gif', '18812345677', 1, 'youlaitech@163.com', now(), NULL, now(), NULL, 0); +INSERT INTO `sys_user` VALUES (2, 'admin', '系统管理员', 1, '$2a$10$xVWsNOhHrCxh5UbpCE7/HuJ.PAOKcYAqRxD2CO2nVnJS.IAXkr5aq', 1, 'https://foruda.gitee.com/images/1723603502796844527/03cdca2a_716974.gif', '18888888888', 1, 'youlaitech@163.com', now(), NULL, now(), NULL, 0); +INSERT INTO `sys_user` VALUES (3, 'test', '测试小用户', 1, '$2a$10$xVWsNOhHrCxh5UbpCE7/HuJ.PAOKcYAqRxD2CO2nVnJS.IAXkr5aq', 3, 'https://foruda.gitee.com/images/1723603502796844527/03cdca2a_716974.gif', '18812345679', 1, 'youlaitech@163.com', now(), NULL, now(), NULL, 0); +INSERT INTO `sys_user` VALUES (4, 'dept_manager', '部门主管', 1, '$2a$10$xVWsNOhHrCxh5UbpCE7/HuJ.PAOKcYAqRxD2CO2nVnJS.IAXkr5aq', 1, 'https://foruda.gitee.com/images/1723603502796844527/03cdca2a_716974.gif', '18812345680', 1, 'manager@youlaitech.com', now(), NULL, now(), NULL, 0); +INSERT INTO `sys_user` VALUES (5, 'dept_member', '部门成员', 1, '$2a$10$xVWsNOhHrCxh5UbpCE7/HuJ.PAOKcYAqRxD2CO2nVnJS.IAXkr5aq', 1, 'https://foruda.gitee.com/images/1723603502796844527/03cdca2a_716974.gif', '18812345681', 1, 'member@youlaitech.com', now(), NULL, now(), NULL, 0); +INSERT INTO `sys_user` VALUES (6, 'employee', '普通员工', 1, '$2a$10$xVWsNOhHrCxh5UbpCE7/HuJ.PAOKcYAqRxD2CO2nVnJS.IAXkr5aq', 2, 'https://foruda.gitee.com/images/1723603502796844527/03cdca2a_716974.gif', '18812345682', 1, 'employee@youlaitech.com', now(), NULL, now(), NULL, 0); +INSERT INTO `sys_user` VALUES (7, 'custom_user', '自定义权限用户', 1, '$2a$10$xVWsNOhHrCxh5UbpCE7/HuJ.PAOKcYAqRxD2CO2nVnJS.IAXkr5aq', 3, 'https://foruda.gitee.com/images/1723603502796844527/03cdca2a_716974.gif', '18812345683', 1, 'custom@youlaitech.com', now(), NULL, now(), NULL, 0); + +-- ---------------------------- +-- Table structure for sys_user_role +-- ---------------------------- +DROP TABLE IF EXISTS `sys_user_role`; +CREATE TABLE `sys_user_role` ( + `user_id` bigint NOT NULL COMMENT '用户ID', + `role_id` bigint NOT NULL COMMENT '角色ID', + PRIMARY KEY (`user_id`, `role_id`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '用户角色关联表'; + +-- ---------------------------- +-- Records of sys_user_role +-- ---------------------------- +INSERT IGNORE INTO `sys_user_role` VALUES (1, 1); +INSERT IGNORE INTO `sys_user_role` VALUES (2, 2); +INSERT IGNORE INTO `sys_user_role` VALUES (3, 3); +INSERT IGNORE INTO `sys_user_role` VALUES (4, 4); +INSERT IGNORE INTO `sys_user_role` VALUES (5, 5); +INSERT IGNORE INTO `sys_user_role` VALUES (6, 6); +INSERT IGNORE INTO `sys_user_role` VALUES (7, 7); + + +-- ---------------------------- +-- Table structure for sys_log +-- ---------------------------- +DROP TABLE IF EXISTS `sys_log`; +CREATE TABLE `sys_log` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `module` TINYINT NOT NULL COMMENT '模块,数字枚举,参考 LogModule 枚举', + `action_type` TINYINT NOT NULL COMMENT '操作类型,数字枚举,参考 ActionType 枚举', + `title` VARCHAR(100) NOT NULL COMMENT '前端显示标题', + `content` TEXT COMMENT '自定义日志内容', + `operator_id` BIGINT COMMENT '操作人ID', + `operator_name` VARCHAR(50) COMMENT '操作人名称', + `request_uri` VARCHAR(255) COMMENT '请求路径', + `request_method` VARCHAR(10) COMMENT '请求方法', + `ip` VARCHAR(45) COMMENT 'IP地址', + `province` VARCHAR(100) COMMENT '省份', + `city` VARCHAR(100) COMMENT '城市', + `device` VARCHAR(100) COMMENT '设备', + `os` VARCHAR(100) COMMENT '操作系统', + `browser` VARCHAR(100) COMMENT '浏览器', + `status` TINYINT DEFAULT 1 COMMENT '0失败 1成功', + `error_msg` VARCHAR(255) COMMENT '错误信息', + `execution_time` INT COMMENT '执行时间(ms)', + `create_time` DATETIME COMMENT '操作时间', + PRIMARY KEY (`id`) USING BTREE, + KEY `idx_module_action_time` (`module`, `action_type`, `create_time`), + KEY `idx_operator_time` (`operator_id`, `create_time`), + KEY `idx_time` (`create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统操作日志表'; + +-- ---------------------------- +-- Table structure for gen_table +-- ---------------------------- +DROP TABLE IF EXISTS `gen_table`; +CREATE TABLE `gen_table` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `table_name` varchar(100) NOT NULL COMMENT '表名', + `module_name` varchar(100) COMMENT '模块名', + `package_name` varchar(255) NOT NULL COMMENT '包名', + `business_name` varchar(100) NOT NULL COMMENT '业务名', + `entity_name` varchar(100) NOT NULL COMMENT '实体类名', + `author` varchar(50) NOT NULL COMMENT '作者', + `parent_menu_id` bigint COMMENT '上级菜单ID,对应sys_menu的id ', + `remove_table_prefix` varchar(20) COMMENT '要移除的表前缀,如: sys_', + `page_type` varchar(20) COMMENT '页面类型(classic|curd)', + `create_time` datetime COMMENT '创建时间', + `update_time` datetime COMMENT '更新时间', + `is_deleted` tinyint(4) DEFAULT 0 COMMENT '是否删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_tablename` (`table_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='代码生成配置表'; + +-- ---------------------------- +-- Table structure for gen_table_column +-- ---------------------------- +DROP TABLE IF EXISTS `gen_table_column`; +CREATE TABLE `gen_table_column` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `table_id` bigint NOT NULL COMMENT '关联的表配置ID', + `column_name` varchar(100) , + `column_type` varchar(50) , + `column_length` int , + `field_name` varchar(100) NOT NULL COMMENT '字段名称', + `field_type` varchar(100) COMMENT '字段类型', + `field_sort` int COMMENT '字段排序', + `field_comment` varchar(255) COMMENT '字段描述', + `max_length` int , + `is_required` tinyint(1) COMMENT '是否必填', + `is_show_in_list` tinyint(1) DEFAULT '0' COMMENT '是否在列表显示', + `is_show_in_form` tinyint(1) DEFAULT '0' COMMENT '是否在表单显示', + `is_show_in_query` tinyint(1) DEFAULT '0' COMMENT '是否在查询条件显示', + `query_type` tinyint COMMENT '查询方式', + `form_type` tinyint COMMENT '表单类型', + `dict_type` varchar(50) COMMENT '字典类型', + `create_time` datetime COMMENT '创建时间', + `update_time` datetime COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_table_id` (`table_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='代码生成字段配置表'; + +-- ---------------------------- +-- 系统配置表 +-- ---------------------------- +DROP TABLE IF EXISTS `sys_config`; +CREATE TABLE `sys_config` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `config_name` varchar(50) NOT NULL COMMENT '配置名称', + `config_key` varchar(50) NOT NULL COMMENT '配置key', + `config_value` varchar(100) NOT NULL COMMENT '配置值', + `remark` varchar(255) COMMENT '备注', + `create_time` datetime COMMENT '创建时间', + `create_by` bigint COMMENT '创建人ID', + `update_time` datetime COMMENT '更新时间', + `update_by` bigint COMMENT '更新人ID', + `is_deleted` tinyint(4) DEFAULT '0' NOT NULL COMMENT '逻辑删除标识(0-未删除 1-已删除)', + PRIMARY KEY (`id`) +) ENGINE=InnoDB COMMENT='系统配置表'; + +INSERT INTO `sys_config` VALUES (1, '系统限流QPS', 'IP_QPS_THRESHOLD_LIMIT', '10', '单个IP请求的最大每秒查询数(QPS)阈值Key', now(), 1, NULL, NULL, 0); + +-- ---------------------------- +-- 通知公告表 +-- ---------------------------- +DROP TABLE IF EXISTS `sys_notice`; +CREATE TABLE `sys_notice` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `title` varchar(50) COMMENT '通知标题', + `content` text COMMENT '通知内容', + `type` tinyint NOT NULL COMMENT '通知类型(关联字典编码:notice_type)', + `level` varchar(5) NOT NULL COMMENT '通知等级(字典code:notice_level)', + `target_type` tinyint NOT NULL COMMENT '目标类型(1: 全体, 2: 指定)', + `target_user_ids` varchar(255) COMMENT '目标人ID集合(多个使用英文逗号,分割)', + `publisher_id` bigint COMMENT '发布人ID', + `publish_status` tinyint DEFAULT '0' COMMENT '发布状态(0: 未发布, 1: 已发布, -1: 已撤回)', + `publish_time` datetime COMMENT '发布时间', + `revoke_time` datetime COMMENT '撤回时间', + `create_by` bigint NOT NULL COMMENT '创建人ID', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_by` bigint COMMENT '更新人ID', + `update_time` datetime COMMENT '更新时间', + `is_deleted` tinyint(1) DEFAULT '0' COMMENT '是否删除(0: 未删除, 1: 已删除)', + PRIMARY KEY (`id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统通知公告表'; + +INSERT INTO `sys_notice` VALUES (1, 'v3.0.0 版本发布 - 多租户功能上线', '

🎉 新版本发布,主要更新内容:

1. 新增多租户功能,支持租户隔离和数据管理

2. 优化系统性能,提升响应速度

3. 完善权限管理,增强安全性

4. 修复已知问题,提升系统稳定性

', 1, 'H', 1, NULL, 1, 1, '2024-12-15 10:00:00', NULL, 1, '2024-12-15 10:00:00', 1, '2024-12-15 10:00:00', 0); +INSERT INTO `sys_notice` VALUES (2, '系统维护通知 - 2024年12月20日', '

⏰ 系统维护通知

系统将于 2024年12月20日(本周五)凌晨 2:00-4:00 进行例行维护升级。

维护期间系统将暂停服务,请提前做好数据备份工作。

给您带来的不便,敬请谅解!

', 2, 'H', 1, NULL, 1, 1, '2024-12-18 14:30:00', NULL, 1, '2024-12-18 14:30:00', 1, '2024-12-18 14:30:00', 0); +INSERT INTO `sys_notice` VALUES (3, '安全提醒 - 防范钓鱼邮件', '

⚠️ 安全提醒

近期发现有不法分子通过钓鱼邮件进行网络攻击,请大家提高警惕:

1. 不要点击来源不明的邮件链接

2. 不要下载可疑附件

3. 遇到可疑邮件请及时联系IT部门

4. 定期修改密码,使用强密码策略

', 3, 'H', 1, NULL, 1, 1, '2024-12-10 09:00:00', NULL, 1, '2024-12-10 09:00:00', 1, '2024-12-10 09:00:00', 0); +INSERT INTO `sys_notice` VALUES (4, '元旦假期安排通知', '

📅 元旦假期安排

根据国家法定节假日安排,公司元旦假期时间为:

2024年12月30日(周一)至 2025年1月1日(周三),共3天。

2024年12月29日(周日)正常上班。

祝大家元旦快乐,假期愉快!

', 4, 'M', 1, NULL, 1, 1, '2024-12-25 16:00:00', NULL, 1, '2024-12-25 16:00:00', 1, '2024-12-25 16:00:00', 0); +INSERT INTO `sys_notice` VALUES (5, '新产品发布会邀请', '

🎊 新产品发布会邀请

公司将于 2025年1月15日下午14:00 在总部会议室举办新产品发布会。

届时将展示最新研发的产品和技术成果,欢迎全体员工参加。

请各部门提前安排好工作,准时参加。

', 5, 'M', 1, NULL, 1, 1, '2024-12-28 11:00:00', NULL, 1, '2024-12-28 11:00:00', 1, '2024-12-28 11:00:00', 0); +INSERT INTO `sys_notice` VALUES (6, 'v2.16.1 版本更新', '

✨ 版本更新

v2.16.1 版本已发布,主要修复内容:

1. 修复 WebSocket 重复连接导致的后台线程阻塞问题

2. 优化通知公告功能,提升用户体验

3. 修复部分已知bug

建议尽快更新到最新版本。

', 1, 'M', 1, NULL, 1, 1, '2024-12-05 15:30:00', NULL, 1, '2024-12-05 15:30:00', 1, '2024-12-05 15:30:00', 0); +INSERT INTO `sys_notice` VALUES (7, '年终总结会议通知', '

📋 年终总结会议通知

各部门年终总结会议将于 2024年12月30日上午9:00 召开。

请各部门负责人提前准备好年度工作总结和下年度工作计划。

会议地点:总部大会议室

', 5, 'M', 2, '1,2', 1, 1, '2024-12-22 10:00:00', NULL, 1, '2024-12-22 10:00:00', 1, '2024-12-22 10:00:00', 0); +INSERT INTO `sys_notice` VALUES (8, '系统功能优化完成', '

✅ 系统功能优化

已完成以下功能优化:

1. 优化用户管理界面,提升操作体验

2. 增强数据导出功能,支持更多格式

3. 优化搜索功能,提升查询效率

4. 修复部分界面显示问题

', 1, 'L', 1, NULL, 1, 1, '2024-12-12 14:20:00', NULL, 1, '2024-12-12 14:20:00', 1, '2024-12-12 14:20:00', 0); +INSERT INTO `sys_notice` VALUES (9, '员工培训计划', '

📚 员工培训计划

为提升员工专业技能,公司将于 2025年1月8日-10日 组织技术培训。

培训内容:

1. 新技术框架应用

2. 代码规范与最佳实践

3. 系统架构设计

请各部门合理安排工作,确保培训顺利进行。

', 5, 'M', 1, NULL, 1, 1, '2024-12-20 09:30:00', NULL, 1, '2024-12-20 09:30:00', 1, '2024-12-20 09:30:00', 0); +INSERT INTO `sys_notice` VALUES (10, '数据备份提醒', '

💾 数据备份提醒

请各部门注意定期备份重要数据,建议每周至少备份一次。

备份方式:

1. 使用系统自带备份功能

2. 手动导出重要数据

3. 联系IT部门协助备份

数据安全,人人有责!

', 3, 'L', 1, NULL, 1, 1, '2024-12-08 08:00:00', NULL, 1, '2024-12-08 08:00:00', 1, '2024-12-08 08:00:00', 0); + +-- ---------------------------- +-- 用户通知公告表 +-- ---------------------------- +DROP TABLE IF EXISTS `sys_user_notice`; +CREATE TABLE `sys_user_notice` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'id', + `notice_id` bigint NOT NULL COMMENT '公共通知id', + `user_id` bigint NOT NULL COMMENT '用户id', + `is_read` tinyint DEFAULT '0' COMMENT '读取状态(0: 未读, 1: 已读)', + `read_time` datetime COMMENT '阅读时间', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_time` datetime COMMENT '更新时间', + `is_deleted` tinyint DEFAULT '0' COMMENT '逻辑删除(0: 未删除, 1: 已删除)', + PRIMARY KEY (`id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户通知公告关联表'; + +INSERT INTO `sys_user_notice` VALUES (1, 1, 2, 1, NULL, now(), now(), 0); +INSERT INTO `sys_user_notice` VALUES (2, 2, 2, 1, NULL, now(), now(), 0); +INSERT INTO `sys_user_notice` VALUES (3, 3, 2, 1, NULL, now(), now(), 0); +INSERT INTO `sys_user_notice` VALUES (4, 4, 2, 1, NULL, now(), now(), 0); +INSERT INTO `sys_user_notice` VALUES (5, 5, 2, 1, NULL, now(), now(), 0); +INSERT INTO `sys_user_notice` VALUES (6, 6, 2, 1, NULL, now(), now(), 0); +INSERT INTO `sys_user_notice` VALUES (7, 7, 2, 1, NULL, now(), now(), 0); +INSERT INTO `sys_user_notice` VALUES (8, 8, 2, 1, NULL, now(), now(), 0); +INSERT INTO `sys_user_notice` VALUES (9, 9, 2, 1, NULL, now(), now(), 0); +INSERT INTO `sys_user_notice` VALUES (10, 10, 2, 1, NULL, now(), now(), 0); + +-- ---------------------------- +-- Table structure for sys_user_social +-- ---------------------------- +DROP TABLE IF EXISTS `sys_user_social`; +CREATE TABLE `sys_user_social` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `user_id` bigint NOT NULL COMMENT '用户ID', + `platform` varchar(20) NOT NULL COMMENT '平台类型(WECHAT_MINI/WECHAT_MP/ALIPAY/QQ/APPLE)', + `openid` varchar(64) NOT NULL COMMENT '平台openid', + `unionid` varchar(64) DEFAULT NULL COMMENT '微信unionid', + `nickname` varchar(64) DEFAULT NULL COMMENT '第三方昵称', + `avatar` varchar(255) DEFAULT NULL COMMENT '第三方头像URL', + `session_key` varchar(128) DEFAULT NULL COMMENT '微信session_key', + `verified` tinyint(1) DEFAULT 1 COMMENT '是否已验证(1-已验证 0-未验证)', + `create_time` datetime DEFAULT NULL COMMENT '绑定时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_platform_openid` (`platform`, `openid`), + KEY `idx_user_id` (`user_id`), + KEY `idx_unionid` (`unionid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户第三方账号绑定表'; diff --git a/sql/mysql/youlai_admin_template.sql b/sql/mysql/youlai_admin_template.sql new file mode 100644 index 0000000..48db945 --- /dev/null +++ b/sql/mysql/youlai_admin_template.sql @@ -0,0 +1,524 @@ +# YouLai_Admin 数据库(MySQL 5.7 ~ MySQL 8.x) +# Copyright (c) 2021-present, youlai.tech + + +-- ---------------------------- +-- 1. 创建数据库 +-- ---------------------------- +CREATE DATABASE IF NOT EXISTS youlai_admin_template CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + + +-- ---------------------------- +-- 2. 创建表 && 数据初始化 +-- ---------------------------- +USE youlai_admin_template; + +SET NAMES utf8mb4; # 设置字符集 +SET FOREIGN_KEY_CHECKS = 0; # 关闭外键检查,加快导入速度 + +-- ---------------------------- +-- Table structure for sys_dept +-- ---------------------------- +DROP TABLE IF EXISTS `sys_dept`; +CREATE TABLE `sys_dept` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `name` varchar(100) NOT NULL COMMENT '部门名称', + `code` varchar(100) NOT NULL COMMENT '部门编号', + `parent_id` bigint DEFAULT 0 COMMENT '父节点id', + `tree_path` varchar(255) NOT NULL COMMENT '父节点id路径', + `sort` smallint DEFAULT 0 COMMENT '显示顺序', + `status` tinyint DEFAULT 1 COMMENT '状态(1-正常 0-禁用)', + `create_by` bigint NULL COMMENT '创建人ID', + `create_time` datetime NULL COMMENT '创建时间', + `update_by` bigint NULL COMMENT '修改人ID', + `update_time` datetime NULL COMMENT '更新时间', + `is_deleted` tinyint DEFAULT 0 COMMENT '逻辑删除标识(1-已删除 0-未删除)', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_code`(`code` ASC) USING BTREE COMMENT '部门编号唯一索引' +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '部门管理表'; + +-- ---------------------------- +-- Records of sys_dept +-- ---------------------------- +INSERT INTO `sys_dept` VALUES (1, '有来技术', 'YOULAI', 0, '0', 1, 1, 1, NULL, 1, now(), 0); +INSERT INTO `sys_dept` VALUES (2, '研发部门', 'RD001', 1, '0,1', 1, 1, 2, NULL, 2, now(), 0); +INSERT INTO `sys_dept` VALUES (3, '测试部门', 'QA001', 1, '0,1', 1, 1, 2, NULL, 2, now(), 0); + +-- ---------------------------- +-- Table structure for sys_dict +-- ---------------------------- +DROP TABLE IF EXISTS `sys_dict`; +CREATE TABLE `sys_dict` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键 ', + `dict_code` varchar(50) COMMENT '类型编码', + `name` varchar(50) COMMENT '类型名称', + `status` tinyint(1) DEFAULT '0' COMMENT '状态(0:正常;1:禁用)', + `remark` varchar(255) COMMENT '备注', + `create_time` datetime COMMENT '创建时间', + `create_by` bigint COMMENT '创建人ID', + `update_time` datetime COMMENT '更新时间', + `update_by` bigint COMMENT '修改人ID', + `is_deleted` tinyint DEFAULT '0' COMMENT '是否删除(1-删除,0-未删除)', + PRIMARY KEY (`id`) USING BTREE, + KEY `idx_dict_code` (`dict_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据字典类型表'; +-- ---------------------------- +-- Records of sys_dict +-- ---------------------------- +INSERT INTO `sys_dict` VALUES (1, 'gender', '性别', 1, NULL, now() , 1,now(), 1,0); +INSERT INTO `sys_dict` VALUES (2, 'notice_type', '通知类型', 1, NULL, now(), 1,now(), 1,0); +INSERT INTO `sys_dict` VALUES (3, 'notice_level', '通知级别', 1, NULL, now(), 1,now(), 1,0); + + +-- ---------------------------- +-- Table structure for sys_dict_item +-- ---------------------------- +DROP TABLE IF EXISTS `sys_dict_item`; +CREATE TABLE `sys_dict_item` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `dict_code` varchar(50) COMMENT '关联字典编码,与sys_dict表中的dict_code对应', + `value` varchar(50) COMMENT '字典项值', + `label` varchar(100) COMMENT '字典项标签', + `tag_type` varchar(50) COMMENT '标签类型,用于前端样式展示(如success、warning等)', + `status` tinyint DEFAULT '0' COMMENT '状态(1-正常,0-禁用)', + `sort` int DEFAULT '0' COMMENT '排序', + `remark` varchar(255) COMMENT '备注', + `create_time` datetime COMMENT '创建时间', + `create_by` bigint COMMENT '创建人ID', + `update_time` datetime COMMENT '更新时间', + `update_by` bigint COMMENT '修改人ID', + PRIMARY KEY (`id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据字典项表'; + +-- ---------------------------- +-- Records of sys_dict_item +-- ---------------------------- +INSERT INTO `sys_dict_item` VALUES (1, 'gender', '1', '男', 'primary', 1, 1, NULL, now(), 1,now(),1); +INSERT INTO `sys_dict_item` VALUES (2, 'gender', '2', '女', 'danger', 1, 2, NULL, now(), 1,now(),1); +INSERT INTO `sys_dict_item` VALUES (3, 'gender', '0', '保密', 'info', 1, 3, NULL, now(), 1,now(),1); +INSERT INTO `sys_dict_item` VALUES (4, 'notice_type', '1', '系统升级', 'success', 1, 1, '', now(), 1,now(),1); +INSERT INTO `sys_dict_item` VALUES (5, 'notice_type', '2', '系统维护', 'primary', 1, 2, '', now(), 1,now(),1); +INSERT INTO `sys_dict_item` VALUES (6, 'notice_type', '3', '安全警告', 'danger', 1, 3, '', now(), 1,now(),1); +INSERT INTO `sys_dict_item` VALUES (7, 'notice_type', '4', '假期通知', 'success', 1, 4, '', now(), 1,now(),1); +INSERT INTO `sys_dict_item` VALUES (8, 'notice_type', '5', '公司新闻', 'primary', 1, 5, '', now(), 1,now(),1); +INSERT INTO `sys_dict_item` VALUES (9, 'notice_type', '99', '其他', 'info', 1, 99, '', now(), 1,now(),1); +INSERT INTO `sys_dict_item` VALUES (10, 'notice_level', 'L', '低', 'info', 1, 1, '', now(), 1,now(),1); +INSERT INTO `sys_dict_item` VALUES (11, 'notice_level', 'M', '中', 'warning', 1, 2, '', now(), 1,now(),1); +INSERT INTO `sys_dict_item` VALUES (12, 'notice_level', 'H', '高', 'danger', 1, 3, '', now(), 1,now(),1); + +-- ---------------------------- +-- Table structure for sys_menu +-- ---------------------------- +DROP TABLE IF EXISTS `sys_menu`; +CREATE TABLE `sys_menu` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID', + `parent_id` bigint NOT NULL COMMENT '父菜单ID', + `tree_path` varchar(255) COMMENT '父节点ID路径', + `name` varchar(64) NOT NULL COMMENT '菜单名称', + `type` char(1) NOT NULL COMMENT '菜单类型(C-目录 M-菜单 B-按钮)', + `route_name` varchar(255) COMMENT '路由名称(Vue Router 中用于命名路由)', + `route_path` varchar(128) COMMENT '路由路径(Vue Router 中定义的 URL 路径)', + `component` varchar(128) COMMENT '组件路径(组件页面完整路径,相对于 src/views/,缺省后缀 .vue)', + `perm` varchar(128) COMMENT '【按钮】权限标识', + `always_show` tinyint DEFAULT 0 COMMENT '【目录】只有一个子路由是否始终显示(1-是 0-否)', + `keep_alive` tinyint DEFAULT 0 COMMENT '【菜单】是否开启页面缓存(1-是 0-否)', + `visible` tinyint(1) DEFAULT 1 COMMENT '显示状态(1-显示 0-隐藏)', + `sort` int DEFAULT 0 COMMENT '排序', + `icon` varchar(64) COMMENT '菜单图标', + `redirect` varchar(128) COMMENT '跳转路径', + `create_time` datetime NULL COMMENT '创建时间', + `update_time` datetime NULL COMMENT '更新时间', + `params` varchar(255) NULL COMMENT '路由参数', + PRIMARY KEY (`id`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '系统菜单表'; + +-- ---------------------------- +-- Records of sys_menu +-- ---------------------------- +-- 顶级目录:系统管理/代码生成/平台文档/接口文档 +INSERT INTO `sys_menu` VALUES (1, 0, '0', '系统管理', 'C', '', '/system', 'Layout', NULL, NULL, NULL, 1, 1, 'system', '/system/user', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2, 0, '0', '代码生成', 'C', '', '/codegen', 'Layout', NULL, NULL, NULL, 1, 2, 'code', '/codegen/index', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (4, 0, '0', '平台文档', 'C', '', '/doc', 'Layout', NULL, NULL, NULL, 1, 4, 'document', '', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (5, 0, '0', '接口文档', 'C', '', '/api', 'Layout', NULL, NULL, NULL, 1, 5, 'api', '', now(), now(), NULL); + +-- 系统管理 +INSERT INTO `sys_menu` VALUES (210, 1, '0,1', '用户管理', 'M', 'User', 'user', 'system/user/index', NULL, NULL, 1, 1, 1, 'el-icon-User', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2101, 210, '0,1,210', '用户查询', 'B', NULL, '', NULL, 'sys:user:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2102, 210, '0,1,210', '用户新增', 'B', NULL, '', NULL, 'sys:user:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2103, 210, '0,1,210', '用户编辑', 'B', NULL, '', NULL, 'sys:user:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2104, 210, '0,1,210', '用户删除', 'B', NULL, '', NULL, 'sys:user:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2105, 210, '0,1,210', '重置密码', 'B', NULL, '', NULL, 'sys:user:reset-password', NULL, NULL, 1, 5, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2106, 210, '0,1,210', '用户导入', 'B', NULL, '', NULL, 'sys:user:import', NULL, NULL, 1, 6, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2107, 210, '0,1,210', '用户导出', 'B', NULL, '', NULL, 'sys:user:export', NULL, NULL, 1, 7, '', NULL, now(), now(), NULL); + +INSERT INTO `sys_menu` VALUES (220, 1, '0,1', '角色管理', 'M', 'Role', 'role', 'system/role/index', NULL, NULL, 1, 1, 2, 'role', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2201, 220, '0,1,220', '角色查询', 'B', NULL, '', NULL, 'sys:role:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2202, 220, '0,1,220', '角色新增', 'B', NULL, '', NULL, 'sys:role:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2203, 220, '0,1,220', '角色编辑', 'B', NULL, '', NULL, 'sys:role:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2204, 220, '0,1,220', '角色删除', 'B', NULL, '', NULL, 'sys:role:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2205, 220, '0,1,220', '角色分配权限', 'B', NULL, '', NULL, 'sys:role:assign', NULL, NULL, 1, 5, '', NULL, now(), now(), NULL); + +INSERT INTO `sys_menu` VALUES (230, 1, '0,1', '菜单管理', 'M', 'SysMenu', 'menu', 'system/menu/index', NULL, NULL, 1, 1, 3, 'menu', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2301, 230, '0,1,230', '菜单查询', 'B', NULL, '', NULL, 'sys:menu:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2302, 230, '0,1,230', '菜单新增', 'B', NULL, '', NULL, 'sys:menu:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2303, 230, '0,1,230', '菜单编辑', 'B', NULL, '', NULL, 'sys:menu:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2304, 230, '0,1,230', '菜单删除', 'B', NULL, '', NULL, 'sys:menu:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); + +INSERT INTO `sys_menu` VALUES (240, 1, '0,1', '部门管理', 'M', 'Dept', 'dept', 'system/dept/index', NULL, NULL, 1, 1, 4, 'tree', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2401, 240, '0,1,240', '部门查询', 'B', NULL, '', NULL, 'sys:dept:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2402, 240, '0,1,240', '部门新增', 'B', NULL, '', NULL, 'sys:dept:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2403, 240, '0,1,240', '部门编辑', 'B', NULL, '', NULL, 'sys:dept:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2404, 240, '0,1,240', '部门删除', 'B', NULL, '', NULL, 'sys:dept:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); + +INSERT INTO `sys_menu` VALUES (250, 1, '0,1', '字典管理', 'M', 'Dict', 'dict', 'system/dict/index', NULL, NULL, 1, 1, 5, 'dict', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2501, 250, '0,1,250', '字典查询', 'B', NULL, '', NULL, 'sys:dict:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2502, 250, '0,1,250', '字典新增', 'B', NULL, '', NULL, 'sys:dict:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2503, 250, '0,1,250', '字典编辑', 'B', NULL, '', NULL, 'sys:dict:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2504, 250, '0,1,250', '字典删除', 'B', NULL, '', NULL, 'sys:dict:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); + +INSERT INTO `sys_menu` VALUES (251, 1, '0,1', '字典项', 'M', 'DictItem', 'dict-item', 'system/dict/dict-item', NULL, 0, 1, 0, 6, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2511, 251, '0,1,251', '字典项查询', 'B', NULL, '', NULL, 'sys:dict-item:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2512, 251, '0,1,251', '字典项新增', 'B', NULL, '', NULL, 'sys:dict-item:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2513, 251, '0,1,251', '字典项编辑', 'B', NULL, '', NULL, 'sys:dict-item:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2514, 251, '0,1,251', '字典项删除', 'B', NULL, '', NULL, 'sys:dict-item:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); + +INSERT INTO `sys_menu` VALUES (260, 1, '0,1', '系统日志', 'M', 'Log', 'log', 'system/log/index', NULL, 0, 1, 1, 7, 'document', NULL, now(), now(), NULL); + +INSERT INTO `sys_menu` VALUES (270, 1, '0,1', '系统配置', 'M', 'Config', 'config', 'system/config/index', NULL, 0, 1, 1, 8, 'setting', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2701, 270, '0,1,270', '系统配置查询', 'B', NULL, '', NULL, 'sys:config:list', 0, 1, 1, 1, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2702, 270, '0,1,270', '系统配置新增', 'B', NULL, '', NULL, 'sys:config:create', 0, 1, 1, 2, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2703, 270, '0,1,270', '系统配置修改', 'B', NULL, '', NULL, 'sys:config:update', 0, 1, 1, 3, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2704, 270, '0,1,270', '系统配置删除', 'B', NULL, '', NULL, 'sys:config:delete', 0, 1, 1, 4, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2705, 270, '0,1,270', '系统配置刷新', 'B', NULL, '', NULL, 'sys:config:refresh', 0, 1, 1, 5, '', NULL, now(), now(), NULL); + +INSERT INTO `sys_menu` VALUES (280, 1, '0,1', '通知公告', 'M', 'Notice', 'notice', 'system/notice/index', NULL, NULL, NULL, 1, 9, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2801, 280, '0,1,280', '通知查询', 'B', NULL, '', NULL, 'sys:notice:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2802, 280, '0,1,280', '通知新增', 'B', NULL, '', NULL, 'sys:notice:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2803, 280, '0,1,280', '通知编辑', 'B', NULL, '', NULL, 'sys:notice:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2804, 280, '0,1,280', '通知删除', 'B', NULL, '', NULL, 'sys:notice:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2805, 280, '0,1,280', '通知发布', 'B', NULL, '', NULL, 'sys:notice:publish', 0, 1, 1, 5, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (2806, 280, '0,1,280', '通知撤回', 'B', NULL, '', NULL, 'sys:notice:revoke', 0, 1, 1, 6, '', NULL, now(), now(), NULL); + +-- 代码生成 +INSERT INTO `sys_menu` VALUES (310, 2, '0,2', '代码生成', 'M', 'Codegen', 'codegen', 'codegen/index', NULL, NULL, 1, 1, 1, 'code', NULL, now(), now(), NULL); + +-- 平台文档(外链通过 route_path 识别) +INSERT INTO `sys_menu` VALUES (501, 4, '0,4', '平台文档(外链)', 'M', NULL, 'https://juejin.cn/post/7228990409909108793', '', NULL, NULL, NULL, 1, 1, 'document', '', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (502, 4, '0,4', '后端文档', 'M', NULL, 'https://youlai.blog.csdn.net/article/details/145178880', '', NULL, NULL, NULL, 1, 2, 'document', '', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (503, 4, '0,4', '移动端文档', 'M', NULL, 'https://youlai.blog.csdn.net/article/details/143222890', '', NULL, NULL, NULL, 1, 3, 'document', '', now(), now(), NULL); +INSERT INTO `sys_menu` VALUES (504, 4, '0,4', '内部文档', 'M', NULL, 'internal-doc', 'demo/internal-doc', NULL, NULL, NULL, 1, 4, 'document', '', now(), now(), NULL); + +-- 接口文档 +INSERT INTO `sys_menu` VALUES (601, 5, '0,5', 'Apifox', 'M', 'Apifox', 'apifox', 'demo/api/apifox', NULL, NULL, 1, 1, 1, 'api', '', now(), now(), NULL); + +-- ---------------------------- +-- Table structure for sys_role +-- ---------------------------- +DROP TABLE IF EXISTS `sys_role`; +CREATE TABLE `sys_role` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(64) NOT NULL COMMENT '角色名称', + `code` varchar(32) NOT NULL COMMENT '角色编码', + `sort` int NULL COMMENT '显示顺序', + `status` tinyint(1) DEFAULT 1 COMMENT '角色状态(1-正常 0-停用)', + `data_scope` tinyint NULL COMMENT '数据权限(1-所有数据 2-部门及子部门数据 3-本部门数据 4-本人数据 5-自定义部门数据)', + `create_by` bigint NULL COMMENT '创建人 ID', + `create_time` datetime NULL COMMENT '创建时间', + `update_by` bigint NULL COMMENT '更新人ID', + `update_time` datetime NULL COMMENT '更新时间', + `is_deleted` tinyint(1) DEFAULT 0 COMMENT '逻辑删除标识(0-未删除 1-已删除)', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_name`(`name` ASC) USING BTREE COMMENT '角色名称唯一索引', + UNIQUE INDEX `uk_code`(`code` ASC) USING BTREE COMMENT '角色编码唯一索引' +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '系统角色表'; + +-- ---------------------------- +-- Records of sys_role +-- ---------------------------- +INSERT INTO `sys_role` VALUES (1, '超级管理员', 'ROOT', 1, 1, 1, NULL, now(), NULL, now(), 0); +INSERT INTO `sys_role` VALUES (2, '系统管理员', 'ADMIN', 2, 1, 1, NULL, now(), NULL, NULL, 0); +INSERT INTO `sys_role` VALUES (3, '访问游客', 'GUEST', 3, 1, 3, NULL, now(), NULL, now(), 0); +INSERT INTO `sys_role` VALUES (4, '部门主管', 'DEPT_MANAGER', 4, 1, 2, NULL, now(), NULL, now(), 0); +INSERT INTO `sys_role` VALUES (5, '部门成员', 'DEPT_MEMBER', 5, 1, 3, NULL, now(), NULL, now(), 0); +INSERT INTO `sys_role` VALUES (6, '普通员工', 'EMPLOYEE', 6, 1, 4, NULL, now(), NULL, now(), 0); +INSERT INTO `sys_role` VALUES (7, '自定义权限用户', 'CUSTOM_USER', 7, 1, 5, NULL, now(), NULL, now(), 0); + +-- ---------------------------- +-- Table structure for sys_role_menu +-- ---------------------------- +DROP TABLE IF EXISTS `sys_role_menu`; +CREATE TABLE `sys_role_menu` ( + `role_id` bigint NOT NULL COMMENT '角色ID', + `menu_id` bigint NOT NULL COMMENT '菜单ID', + UNIQUE INDEX `uk_roleid_menuid`(`role_id` ASC, `menu_id` ASC) USING BTREE COMMENT '角色菜单唯一索引' +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '角色菜单关联表'; + +-- ---------------------------- +-- Table structure for sys_role_dept +-- ---------------------------- +DROP TABLE IF EXISTS `sys_role_dept`; +CREATE TABLE `sys_role_dept` ( + `role_id` bigint NOT NULL COMMENT '角色ID', + `dept_id` bigint NOT NULL COMMENT '部门ID', + UNIQUE INDEX `uk_roleid_deptid`(`role_id` ASC, `dept_id` ASC) USING BTREE COMMENT '角色部门唯一索引' +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '角色部门关联表'; + +-- ---------------------------- +-- Records of sys_role_dept +-- ---------------------------- +INSERT IGNORE INTO `sys_role_dept` VALUES (7, 1); +INSERT IGNORE INTO `sys_role_dept` VALUES (7, 2); + +-- ============================================ +-- 系统管理员角色菜单权限(role_id=2) +-- 顶级目录 +INSERT INTO `sys_role_menu` VALUES (2, 1), (2, 2), (2, 4), (2, 5); +-- 系统管理 +INSERT INTO `sys_role_menu` VALUES (2, 210), (2, 2101), (2, 2102), (2, 2103), (2, 2104), (2, 2105), (2, 2106), (2, 2107); +INSERT INTO `sys_role_menu` VALUES (2, 220), (2, 2201), (2, 2202), (2, 2203), (2, 2204), (2, 2205); +INSERT INTO `sys_role_menu` VALUES (2, 230), (2, 2301), (2, 2302), (2, 2303), (2, 2304); +INSERT INTO `sys_role_menu` VALUES (2, 240), (2, 2401), (2, 2402), (2, 2403), (2, 2404); +INSERT INTO `sys_role_menu` VALUES (2, 250), (2, 2501), (2, 2502), (2, 2503), (2, 2504); +INSERT INTO `sys_role_menu` VALUES (2, 251), (2, 2511), (2, 2512), (2, 2513), (2, 2514); +INSERT INTO `sys_role_menu` VALUES (2, 260), (2, 2601); +INSERT INTO `sys_role_menu` VALUES (2, 270), (2, 2701), (2, 2702), (2, 2703), (2, 2704), (2, 2705); +INSERT INTO `sys_role_menu` VALUES (2, 280), (2, 2801), (2, 2802), (2, 2803), (2, 2804), (2, 2805), (2, 2806); + +INSERT IGNORE INTO `sys_role_menu` VALUES (4, 1); +INSERT IGNORE INTO `sys_role_menu` VALUES (4, 210), (4, 2101), (4, 2102), (4, 2103), (4, 2104), (4, 2105), (4, 2106), (4, 2107); +INSERT IGNORE INTO `sys_role_menu` VALUES (4, 220), (4, 2201), (4, 2202), (4, 2203), (4, 2204), (4, 2205); + +INSERT IGNORE INTO `sys_role_menu` VALUES (5, 1); +INSERT IGNORE INTO `sys_role_menu` VALUES (5, 210), (5, 2101), (5, 2102), (5, 2103), (5, 2104), (5, 2105), (5, 2106), (5, 2107); +INSERT IGNORE INTO `sys_role_menu` VALUES (5, 220), (5, 2201), (5, 2202), (5, 2203), (5, 2204), (5, 2205); + +INSERT IGNORE INTO `sys_role_menu` VALUES (6, 1); +INSERT IGNORE INTO `sys_role_menu` VALUES (6, 210), (6, 2101), (6, 2102), (6, 2103), (6, 2104), (6, 2105), (6, 2106), (6, 2107); +INSERT IGNORE INTO `sys_role_menu` VALUES (6, 220), (6, 2201), (6, 2202), (6, 2203), (6, 2204), (6, 2205); + +INSERT IGNORE INTO `sys_role_menu` VALUES (7, 1); +INSERT IGNORE INTO `sys_role_menu` VALUES (7, 210), (7, 2101), (7, 2102), (7, 2103), (7, 2104), (7, 2105), (7, 2106), (7, 2107); +INSERT IGNORE INTO `sys_role_menu` VALUES (7, 220), (7, 2201), (7, 2202), (7, 2203), (7, 2204), (7, 2205); +-- 代码生成 +INSERT INTO `sys_role_menu` VALUES (2, 310); +-- 平台文档 +INSERT INTO `sys_role_menu` VALUES (2, 501), (2, 502), (2, 503), (2, 504); +-- 接口文档 +INSERT INTO `sys_role_menu` VALUES (2, 601); + +-- ---------------------------- +-- Table structure for sys_user +-- ---------------------------- +DROP TABLE IF EXISTS `sys_user`; +CREATE TABLE `sys_user` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `username` varchar(64) COMMENT '用户名', + `nickname` varchar(64) COMMENT '昵称', + `gender` tinyint(1) DEFAULT 1 COMMENT '性别((1-男 2-女 0-保密)', + `password` varchar(100) COMMENT '密码', + `dept_id` int COMMENT '部门ID', + `avatar` varchar(255) COMMENT '用户头像', + `mobile` varchar(20) COMMENT '联系方式', + `status` tinyint(1) DEFAULT 1 COMMENT '状态(1-正常 0-禁用)', + `email` varchar(128) COMMENT '用户邮箱', + `create_time` datetime COMMENT '创建时间', + `create_by` bigint COMMENT '创建人ID', + `update_time` datetime COMMENT '更新时间', + `update_by` bigint COMMENT '修改人ID', + `is_deleted` tinyint(1) DEFAULT 0 COMMENT '逻辑删除标识(0-未删除 1-已删除)', + PRIMARY KEY (`id`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '系统用户表'; + +-- ---------------------------- +-- Records of sys_user +-- ---------------------------- +INSERT INTO `sys_user` VALUES (1, 'root', '有来技术', 0, '$2a$10$xVWsNOhHrCxh5UbpCE7/HuJ.PAOKcYAqRxD2CO2nVnJS.IAXkr5aq', NULL, 'https://foruda.gitee.com/images/1723603502796844527/03cdca2a_716974.gif', '18812345677', 1, 'youlaitech@163.com', now(), NULL, now(), NULL, 0); +INSERT INTO `sys_user` VALUES (2, 'admin', '系统管理员', 1, '$2a$10$xVWsNOhHrCxh5UbpCE7/HuJ.PAOKcYAqRxD2CO2nVnJS.IAXkr5aq', 1, 'https://foruda.gitee.com/images/1723603502796844527/03cdca2a_716974.gif', '18888888888', 1, 'youlaitech@163.com', now(), NULL, now(), NULL, 0); +INSERT INTO `sys_user` VALUES (3, 'test', '测试小用户', 1, '$2a$10$xVWsNOhHrCxh5UbpCE7/HuJ.PAOKcYAqRxD2CO2nVnJS.IAXkr5aq', 3, 'https://foruda.gitee.com/images/1723603502796844527/03cdca2a_716974.gif', '18812345679', 1, 'youlaitech@163.com', now(), NULL, now(), NULL, 0); +INSERT INTO `sys_user` VALUES (4, 'dept_manager', '部门主管', 1, '$2a$10$xVWsNOhHrCxh5UbpCE7/HuJ.PAOKcYAqRxD2CO2nVnJS.IAXkr5aq', 1, 'https://foruda.gitee.com/images/1723603502796844527/03cdca2a_716974.gif', '18812345680', 1, 'manager@youlaitech.com', now(), NULL, now(), NULL, 0); +INSERT INTO `sys_user` VALUES (5, 'dept_member', '部门成员', 1, '$2a$10$xVWsNOhHrCxh5UbpCE7/HuJ.PAOKcYAqRxD2CO2nVnJS.IAXkr5aq', 1, 'https://foruda.gitee.com/images/1723603502796844527/03cdca2a_716974.gif', '18812345681', 1, 'member@youlaitech.com', now(), NULL, now(), NULL, 0); +INSERT INTO `sys_user` VALUES (6, 'employee', '普通员工', 1, '$2a$10$xVWsNOhHrCxh5UbpCE7/HuJ.PAOKcYAqRxD2CO2nVnJS.IAXkr5aq', 2, 'https://foruda.gitee.com/images/1723603502796844527/03cdca2a_716974.gif', '18812345682', 1, 'employee@youlaitech.com', now(), NULL, now(), NULL, 0); +INSERT INTO `sys_user` VALUES (7, 'custom_user', '自定义权限用户', 1, '$2a$10$xVWsNOhHrCxh5UbpCE7/HuJ.PAOKcYAqRxD2CO2nVnJS.IAXkr5aq', 3, 'https://foruda.gitee.com/images/1723603502796844527/03cdca2a_716974.gif', '18812345683', 1, 'custom@youlaitech.com', now(), NULL, now(), NULL, 0); + +-- ---------------------------- +-- Table structure for sys_user_role +-- ---------------------------- +DROP TABLE IF EXISTS `sys_user_role`; +CREATE TABLE `sys_user_role` ( + `user_id` bigint NOT NULL COMMENT '用户ID', + `role_id` bigint NOT NULL COMMENT '角色ID', + PRIMARY KEY (`user_id`, `role_id`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '用户角色关联表'; + +-- ---------------------------- +-- Records of sys_user_role +-- ---------------------------- +INSERT INTO `sys_user_role` VALUES (1, 1); +INSERT INTO `sys_user_role` VALUES (2, 2); +INSERT INTO `sys_user_role` VALUES (3, 3); +INSERT IGNORE INTO `sys_user_role` VALUES (4, 4); +INSERT IGNORE INTO `sys_user_role` VALUES (5, 5); +INSERT IGNORE INTO `sys_user_role` VALUES (6, 6); +INSERT IGNORE INTO `sys_user_role` VALUES (7, 7); + +-- ---------------------------- +-- Table structure for sys_log +-- ---------------------------- +DROP TABLE IF EXISTS `sys_log`; +CREATE TABLE `sys_log` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `module` TINYINT NOT NULL COMMENT '模块,数字枚举,参考 LogModule 枚举', + `action_type` TINYINT NOT NULL COMMENT '操作类型,数字枚举,参考 ActionType 枚举', + `title` VARCHAR(100) NOT NULL COMMENT '前端显示标题', + `content` TEXT COMMENT '自定义日志内容', + `operator_id` BIGINT COMMENT '操作人ID', + `operator_name` VARCHAR(50) COMMENT '操作人名称', + `request_uri` VARCHAR(255) COMMENT '请求路径', + `request_method` VARCHAR(10) COMMENT '请求方法', + `ip` VARCHAR(45) COMMENT 'IP地址', + `province` VARCHAR(100) COMMENT '省份', + `city` VARCHAR(100) COMMENT '城市', + `device` VARCHAR(100) COMMENT '设备', + `os` VARCHAR(100) COMMENT '操作系统', + `browser` VARCHAR(100) COMMENT '浏览器', + `status` TINYINT DEFAULT 1 COMMENT '0失败 1成功', + `error_msg` VARCHAR(255) COMMENT '错误信息', + `execution_time` INT COMMENT '执行时间(ms)', + `create_time` DATETIME COMMENT '操作时间', + PRIMARY KEY (`id`) USING BTREE, + KEY `idx_module_action_time` (`module`, `action_type`, `create_time`), + KEY `idx_operator_time` (`operator_id`, `create_time`), + KEY `idx_time` (`create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统操作日志表'; + +-- ---------------------------- +-- Table structure for gen_table +-- ---------------------------- +DROP TABLE IF EXISTS `gen_table`; +CREATE TABLE `gen_table` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `table_name` varchar(100) NOT NULL COMMENT '表名', + `module_name` varchar(100) COMMENT '模块名', + `package_name` varchar(255) NOT NULL COMMENT '包名', + `business_name` varchar(100) NOT NULL COMMENT '业务名', + `entity_name` varchar(100) NOT NULL COMMENT '实体类名', + `author` varchar(50) NOT NULL COMMENT '作者', + `parent_menu_id` bigint COMMENT '上级菜单ID,对应sys_menu的id ', + `remove_table_prefix` varchar(20) COMMENT '要移除的表前缀,如: sys_', + `page_type` varchar(20) COMMENT '页面类型(classic|curd)', + `create_time` datetime COMMENT '创建时间', + `update_time` datetime COMMENT '更新时间', + `is_deleted` tinyint(4) DEFAULT 0 COMMENT '是否删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_tablename` (`table_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='代码生成配置表'; + +-- ---------------------------- +-- Table structure for gen_table_column +-- ---------------------------- +DROP TABLE IF EXISTS `gen_table_column`; +CREATE TABLE `gen_table_column` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `table_id` bigint NOT NULL COMMENT '关联的表配置ID', + `column_name` varchar(100) , + `column_type` varchar(50) , + `column_length` int , + `field_name` varchar(100) NOT NULL COMMENT '字段名称', + `field_type` varchar(100) COMMENT '字段类型', + `field_sort` int COMMENT '字段排序', + `field_comment` varchar(255) COMMENT '字段描述', + `max_length` int , + `is_required` tinyint(1) COMMENT '是否必填', + `is_show_in_list` tinyint(1) DEFAULT '0' COMMENT '是否在列表显示', + `is_show_in_form` tinyint(1) DEFAULT '0' COMMENT '是否在表单显示', + `is_show_in_query` tinyint(1) DEFAULT '0' COMMENT '是否在查询条件显示', + `query_type` tinyint COMMENT '查询方式', + `form_type` tinyint COMMENT '表单类型', + `dict_type` varchar(50) COMMENT '字典类型', + `create_time` datetime COMMENT '创建时间', + `update_time` datetime COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_table_id` (`table_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='代码生成字段配置表'; + +-- ---------------------------- +-- 系统配置表 +-- ---------------------------- +DROP TABLE IF EXISTS `sys_config`; +CREATE TABLE `sys_config` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `config_name` varchar(50) NOT NULL COMMENT '配置名称', + `config_key` varchar(50) NOT NULL COMMENT '配置key', + `config_value` varchar(100) NOT NULL COMMENT '配置值', + `remark` varchar(255) COMMENT '备注', + `create_time` datetime COMMENT '创建时间', + `create_by` bigint COMMENT '创建人ID', + `update_time` datetime COMMENT '更新时间', + `update_by` bigint COMMENT '更新人ID', + `is_deleted` tinyint(4) DEFAULT '0' NOT NULL COMMENT '逻辑删除标识(0-未删除 1-已删除)', + PRIMARY KEY (`id`) +) ENGINE=InnoDB COMMENT='系统配置表'; + +INSERT INTO `sys_config` VALUES (1, '系统限流QPS', 'IP_QPS_THRESHOLD_LIMIT', '10', '单个IP请求的最大每秒查询数(QPS)阈值Key', now(), 1, NULL, NULL, 0); + +-- ---------------------------- +-- 通知公告表 +-- ---------------------------- +DROP TABLE IF EXISTS `sys_notice`; +CREATE TABLE `sys_notice` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `title` varchar(50) COMMENT '通知标题', + `content` text COMMENT '通知内容', + `type` tinyint NOT NULL COMMENT '通知类型(关联字典编码:notice_type)', + `level` varchar(5) NOT NULL COMMENT '通知等级(字典code:notice_level)', + `target_type` tinyint NOT NULL COMMENT '目标类型(1: 全体, 2: 指定)', + `target_user_ids` varchar(255) COMMENT '目标人ID集合(多个使用英文逗号,分割)', + `publisher_id` bigint COMMENT '发布人ID', + `publish_status` tinyint DEFAULT '0' COMMENT '发布状态(0: 未发布, 1: 已发布, -1: 已撤回)', + `publish_time` datetime COMMENT '发布时间', + `revoke_time` datetime COMMENT '撤回时间', + `create_by` bigint NOT NULL COMMENT '创建人ID', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_by` bigint COMMENT '更新人ID', + `update_time` datetime COMMENT '更新时间', + `is_deleted` tinyint(1) DEFAULT '0' COMMENT '是否删除(0: 未删除, 1: 已删除)', + PRIMARY KEY (`id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统通知公告表'; + +INSERT INTO `sys_notice` VALUES (1, 'v3.0.0 版本发布 - 多租户功能上线', '

🎉 新版本发布,主要更新内容:

1. 新增多租户功能,支持租户隔离和数据管理

2. 优化系统性能,提升响应速度

3. 完善权限管理,增强安全性

4. 修复已知问题,提升系统稳定性

', 1, 'H', 1, NULL, 1, 1, '2024-12-15 10:00:00', NULL, 1, '2024-12-15 10:00:00', 1, '2024-12-15 10:00:00', 0); +INSERT INTO `sys_notice` VALUES (2, '系统维护通知 - 2024年12月20日', '

⏰ 系统维护通知

系统将于 2024年12月20日(本周五)凌晨 2:00-4:00 进行例行维护升级。

维护期间系统将暂停服务,请提前做好数据备份工作。

给您带来的不便,敬请谅解!

', 2, 'H', 1, NULL, 1, 1, '2024-12-18 14:30:00', NULL, 1, '2024-12-18 14:30:00', 1, '2024-12-18 14:30:00', 0); +INSERT INTO `sys_notice` VALUES (3, '安全提醒 - 防范钓鱼邮件', '

⚠️ 安全提醒

近期发现有不法分子通过钓鱼邮件进行网络攻击,请大家提高警惕:

1. 不要点击来源不明的邮件链接

2. 不要下载可疑附件

3. 遇到可疑邮件请及时联系IT部门

4. 定期修改密码,使用强密码策略

', 3, 'H', 1, NULL, 1, 1, '2024-12-10 09:00:00', NULL, 1, '2024-12-10 09:00:00', 1, '2024-12-10 09:00:00', 0); +INSERT INTO `sys_notice` VALUES (4, '元旦假期安排通知', '

📅 元旦假期安排

根据国家法定节假日安排,公司元旦假期时间为:

2024年12月30日(周一)至 2025年1月1日(周三),共3天。

2024年12月29日(周日)正常上班。

祝大家元旦快乐,假期愉快!

', 4, 'M', 1, NULL, 1, 1, '2024-12-25 16:00:00', NULL, 1, '2024-12-25 16:00:00', 1, '2024-12-25 16:00:00', 0); +INSERT INTO `sys_notice` VALUES (5, '新产品发布会邀请', '

🎊 新产品发布会邀请

公司将于 2025年1月15日下午14:00 在总部会议室举办新产品发布会。

届时将展示最新研发的产品和技术成果,欢迎全体员工参加。

请各部门提前安排好工作,准时参加。

', 5, 'M', 1, NULL, 1, 1, '2024-12-28 11:00:00', NULL, 1, '2024-12-28 11:00:00', 1, '2024-12-28 11:00:00', 0); +INSERT INTO `sys_notice` VALUES (6, 'v2.16.1 版本更新', '

✨ 版本更新

v2.16.1 版本已发布,主要修复内容:

1. 修复 WebSocket 重复连接导致的后台线程阻塞问题

2. 优化通知公告功能,提升用户体验

3. 修复部分已知bug

建议尽快更新到最新版本。

', 1, 'M', 1, NULL, 1, 1, '2024-12-05 15:30:00', NULL, 1, '2024-12-05 15:30:00', 1, '2024-12-05 15:30:00', 0); +INSERT INTO `sys_notice` VALUES (7, '年终总结会议通知', '

📋 年终总结会议通知

各部门年终总结会议将于 2024年12月30日上午9:00 召开。

请各部门负责人提前准备好年度工作总结和下年度工作计划。

会议地点:总部大会议室

', 5, 'M', 2, '1,2', 1, 1, '2024-12-22 10:00:00', NULL, 1, '2024-12-22 10:00:00', 1, '2024-12-22 10:00:00', 0); +INSERT INTO `sys_notice` VALUES (8, '系统功能优化完成', '

✅ 系统功能优化

已完成以下功能优化:

1. 优化用户管理界面,提升操作体验

2. 增强数据导出功能,支持更多格式

3. 优化搜索功能,提升查询效率

4. 修复部分界面显示问题

', 1, 'L', 1, NULL, 1, 1, '2024-12-12 14:20:00', NULL, 1, '2024-12-12 14:20:00', 1, '2024-12-12 14:20:00', 0); +INSERT INTO `sys_notice` VALUES (9, '员工培训计划', '

📚 员工培训计划

为提升员工专业技能,公司将于 2025年1月8日-10日 组织技术培训。

培训内容:

1. 新技术框架应用

2. 代码规范与最佳实践

3. 系统架构设计

请各部门合理安排工作,确保培训顺利进行。

', 5, 'M', 1, NULL, 1, 1, '2024-12-20 09:30:00', NULL, 1, '2024-12-20 09:30:00', 1, '2024-12-20 09:30:00', 0); +INSERT INTO `sys_notice` VALUES (10, '数据备份提醒', '

💾 数据备份提醒

请各部门注意定期备份重要数据,建议每周至少备份一次。

备份方式:

1. 使用系统自带备份功能

2. 手动导出重要数据

3. 联系IT部门协助备份

数据安全,人人有责!

', 3, 'L', 1, NULL, 1, 1, '2024-12-08 08:00:00', NULL, 1, '2024-12-08 08:00:00', 1, '2024-12-08 08:00:00', 0); + +-- ---------------------------- +-- 用户通知公告表 +-- ---------------------------- +DROP TABLE IF EXISTS `sys_user_notice`; +CREATE TABLE `sys_user_notice` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'id', + `notice_id` bigint NOT NULL COMMENT '公共通知id', + `user_id` bigint NOT NULL COMMENT '用户id', + `is_read` bigint DEFAULT '0' COMMENT '读取状态(0: 未读, 1: 已读)', + `read_time` datetime COMMENT '阅读时间', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_time` datetime COMMENT '更新时间', + `is_deleted` tinyint DEFAULT '0' COMMENT '逻辑删除(0: 未删除, 1: 已删除)', + PRIMARY KEY (`id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户通知公告关联表'; + +INSERT INTO `sys_user_notice` VALUES (1, 1, 2, 1, NULL, now(), now(), 0); +INSERT INTO `sys_user_notice` VALUES (2, 2, 2, 1, NULL, now(), now(), 0); +INSERT INTO `sys_user_notice` VALUES (3, 3, 2, 1, NULL, now(), now(), 0); +INSERT INTO `sys_user_notice` VALUES (4, 4, 2, 1, NULL, now(), now(), 0); +INSERT INTO `sys_user_notice` VALUES (5, 5, 2, 1, NULL, now(), now(), 0); +INSERT INTO `sys_user_notice` VALUES (6, 6, 2, 1, NULL, now(), now(), 0); +INSERT INTO `sys_user_notice` VALUES (7, 7, 2, 1, NULL, now(), now(), 0); +INSERT INTO `sys_user_notice` VALUES (8, 8, 2, 1, NULL, now(), now(), 0); +INSERT INTO `sys_user_notice` VALUES (9, 9, 2, 1, NULL, now(), now(), 0); +INSERT INTO `sys_user_notice` VALUES (10, 10, 2, 1, NULL, now(), now(), 0); diff --git a/src/main/java/com/youlai/boot/YouLaiBootApplication.java b/src/main/java/com/youlai/boot/YouLaiBootApplication.java new file mode 100644 index 0000000..6c44e20 --- /dev/null +++ b/src/main/java/com/youlai/boot/YouLaiBootApplication.java @@ -0,0 +1,19 @@ +package com.youlai.boot; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 应用启动类 + * + * @author Ray.Hao + * @since 0.0.1 + */ +@SpringBootApplication +public class YouLaiBootApplication { + + public static void main(String[] args) { + SpringApplication.run(YouLaiBootApplication.class, args); + } + +} diff --git a/src/main/java/com/youlai/boot/auth/controller/AuthController.java b/src/main/java/com/youlai/boot/auth/controller/AuthController.java new file mode 100644 index 0000000..7a7975c --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/controller/AuthController.java @@ -0,0 +1,86 @@ +package com.youlai.boot.auth.controller; + +import com.youlai.boot.auth.model.LoginReq; +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.framework.captcha.model.CaptchaInfo; +import com.youlai.boot.framework.security.model.AuthenticationToken; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +/** + * 认证控制层 + * + * @author Ray.Hao + * @since 0.0.1 + */ +@Tag(name = "01.认证中心") +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +@Slf4j +public class AuthController { + + private final AuthService authService; + + @Operation(summary = "获取验证码") + @GetMapping("/captcha") + public Result getCaptcha() { + CaptchaInfo captcha = authService.getCaptcha(); + return Result.success(captcha); + } + + @Operation(summary = "账号密码登录") + @PostMapping("/login") + @Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN) + public Result login(@RequestBody @Valid LoginReq request) { + AuthenticationToken authenticationToken = authService.login(request.getUsername(), request.getPassword()); + return Result.success(authenticationToken); + } + + @Operation(summary = "短信验证码登录") + @PostMapping("/login/sms") + @Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN) + public Result loginBySms( + @Parameter(description = "手机号", example = "18888888888") @RequestParam String mobile, + @Parameter(description = "验证码", example = "123456") @RequestParam String code + ) { + AuthenticationToken loginResult = authService.loginBySms(mobile, code); + return Result.success(loginResult); + } + + @Operation(summary = "发送登录短信验证码") + @PostMapping("/sms/code") + public Result sendSmsCode( + @Parameter(description = "手机号", example = "18888888888") @RequestParam String mobile + ) { + authService.sendSmsCode(mobile); + return Result.success(); + } + + @Operation(summary = "退出登录") + @DeleteMapping("/logout") + @Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGOUT) + public Result logout() { + authService.logout(); + return Result.success(); + } + + @Operation(summary = "刷新令牌") + @PostMapping("/refresh-token") + public Result refreshToken( + @Parameter(description = "刷新令牌", example = "xxx.xxx.xxx") @RequestParam String refreshToken + ) { + AuthenticationToken authenticationToken = authService.refreshToken(refreshToken); + return Result.success(authenticationToken); + } + +} diff --git a/src/main/java/com/youlai/boot/auth/controller/WxMaAuthController.java b/src/main/java/com/youlai/boot/auth/controller/WxMaAuthController.java new file mode 100644 index 0000000..132ca36 --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/controller/WxMaAuthController.java @@ -0,0 +1,85 @@ +package com.youlai.boot.auth.controller; + +import com.youlai.boot.auth.model.WxMaBindMobileReq; +import com.youlai.boot.auth.model.WxMaPhoneLoginReq; +import com.youlai.boot.auth.model.WxMaLoginResp; +import com.youlai.boot.auth.service.WxMaAuthService; +import com.youlai.boot.common.annotation.Log; +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.framework.security.model.AuthenticationToken; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; + +/** + * 微信小程序认证控制层 + * + * @author Ray.Hao + * @since 2.4.0 + */ +@Tag(name = "02.微信小程序认证") +@RestController +@RequestMapping("/api/v1/wxma/auth") +@RequiredArgsConstructor +@Slf4j +public class WxMaAuthController { + + private final WxMaAuthService wxMaAuthService; + + /** + * 静默登录 + *

+ * 适用场景:个人小程序、无需手机号的登录场景 + *

    + *
  • 已绑定手机号的用户:直接返回 token,登录成功
  • + *
  • 未绑定手机号的用户:返回 openid,需调用绑定手机号接口
  • + *
+ */ + @Operation(summary = "静默登录", description = "通过微信 code 登录,已绑定用户直接返回 token,未绑定用户返回 openid 需绑定手机号") + @PostMapping("/silent-login") + @Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN) + public Result silentLogin( + @Parameter(description = "微信登录凭证(wx.login 获取)", required = true, example = "0xxx") + @RequestParam String code + ) { + WxMaLoginResp result = wxMaAuthService.silentLogin(code); + return Result.success(result); + } + + /** + * 手机号快捷登录 + *

+ * 适用场景:企业认证小程序(已开通手机号快捷登录权限) + *

+ * 一步完成登录,无需绑定流程,自动创建新用户 + */ + @Operation(summary = "手机号快捷登录", description = "同时使用微信 code 和手机号授权 code 登录,适用于企业认证小程序") + @PostMapping("/phone-login") + @Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN) + public Result phoneLogin(@Valid @RequestBody WxMaPhoneLoginReq req) { + AuthenticationToken result = wxMaAuthService.phoneLogin(req.getLoginCode(), req.getPhoneCode()); + return Result.success(result); + } + + /** + * 绑定手机号 + *

+ * 适用场景:静默登录后未绑定手机号的用户 + *

+ * 绑定成功后自动完成登录 + */ + @Operation(summary = "绑定手机号", description = "为静默登录用户绑定手机号,绑定成功后自动登录") + @PostMapping("/bind-mobile") + @Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN) + public Result bindMobile(@Valid @RequestBody WxMaBindMobileReq req) { + AuthenticationToken result = wxMaAuthService.bindMobile(req.getOpenid(), req.getMobile(), req.getSmsCode()); + return Result.success(result); + } +} diff --git a/src/main/java/com/youlai/boot/auth/model/LoginReq.java b/src/main/java/com/youlai/boot/auth/model/LoginReq.java new file mode 100644 index 0000000..7faaa42 --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/model/LoginReq.java @@ -0,0 +1,31 @@ +package com.youlai.boot.auth.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import jakarta.validation.constraints.NotBlank; + +/** + * 登录请求参数 + * + * @author Ray.Hao + * @since 3.0.0 + */ +@Schema(description = "登录请求参数") +@Data +public class LoginReq { + + @Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "admin") + @NotBlank(message = "用户名不能为空") + private String username; + + @Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456") + @NotBlank(message = "密码不能为空") + private String password; + + @Schema(description = "验证码缓存ID", example = "captcha_id_123") + private String captchaId; + + @Schema(description = "验证码", example = "123456") + private String captchaCode; +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/auth/model/WxMaBindMobileReq.java b/src/main/java/com/youlai/boot/auth/model/WxMaBindMobileReq.java new file mode 100644 index 0000000..cbd3d72 --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/model/WxMaBindMobileReq.java @@ -0,0 +1,28 @@ +package com.youlai.boot.auth.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 微信小程序绑定手机号请求 + * + * @author Ray.Hao + * @since 3.0.0 + */ +@Schema(description = "微信小程序绑定手机号请求") +@Data +public class WxMaBindMobileReq { + + @NotBlank(message = "openid 不能为空") + @Schema(description = "微信用户唯一标识(静默登录返回)", example = "oVBkZ0aYgDMDIywRdgPW8-joxXc4") + private String openid; + + @NotBlank(message = "手机号不能为空") + @Schema(description = "手机号码", example = "18888888888") + private String mobile; + + @NotBlank(message = "短信验证码不能为空") + @Schema(description = "短信验证码", example = "123456") + private String smsCode; +} diff --git a/src/main/java/com/youlai/boot/auth/model/WxMaLoginResp.java b/src/main/java/com/youlai/boot/auth/model/WxMaLoginResp.java new file mode 100644 index 0000000..30e45ad --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/model/WxMaLoginResp.java @@ -0,0 +1,42 @@ +package com.youlai.boot.auth.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 微信小程序登录响应 + * + * @author Ray.Hao + * @since 2.4.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "微信小程序登录响应") +public class WxMaLoginResp { + + @Schema(description = "是否新用户") + private Boolean isNewUser; + + @Schema(description = "是否需要绑定手机号") + private Boolean needBindMobile; + + @Schema(description = "微信openid(绑定手机号时需要)") + private String openid; + + @Schema(description = "访问令牌") + private String accessToken; + + @Schema(description = "刷新令牌") + private String refreshToken; + + @Schema(description = "令牌类型") + private String tokenType; + + @Schema(description = "过期时间(秒)") + private Integer expiresIn; +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/auth/model/WxMaPhoneLoginReq.java b/src/main/java/com/youlai/boot/auth/model/WxMaPhoneLoginReq.java new file mode 100644 index 0000000..cf3ad39 --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/model/WxMaPhoneLoginReq.java @@ -0,0 +1,24 @@ +package com.youlai.boot.auth.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 微信小程序手机号快捷登录请求 + * + * @author Ray.Hao + * @since 3.0.0 + */ +@Schema(description = "微信小程序手机号快捷登录请求") +@Data +public class WxMaPhoneLoginReq { + + @NotBlank(message = "微信登录凭证不能为空") + @Schema(description = "微信登录凭证(wx.login 获取)", example = "0xxx") + private String loginCode; + + @NotBlank(message = "手机号授权凭证不能为空") + @Schema(description = "手机号授权凭证(getPhoneNumber 事件获取)", example = "0xxx") + private String phoneCode; +} diff --git a/src/main/java/com/youlai/boot/auth/service/AuthService.java b/src/main/java/com/youlai/boot/auth/service/AuthService.java new file mode 100644 index 0000000..2247b11 --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/service/AuthService.java @@ -0,0 +1,56 @@ +package com.youlai.boot.auth.service; + +import com.youlai.boot.framework.captcha.model.CaptchaInfo; +import com.youlai.boot.framework.security.model.AuthenticationToken; + +/** + * 认证服务接口 + * + * @author Ray.Hao + * @since 2.4.0 + */ +public interface AuthService { + + /** + * 账号密码登录 + * + * @param username 用户名 + * @param password 密码 + * @return 认证令牌 + */ + AuthenticationToken login(String username, String password); + + /** + * 短信验证码登录 + * + * @param mobile 手机号 + * @param code 验证码 + * @return 认证令牌 + */ + AuthenticationToken loginBySms(String mobile, String code); + + /** + * 发送短信验证码 + * + * @param mobile 手机号 + */ + void sendSmsCode(String mobile); + + /** + * 退出登录 + */ + void logout(); + + /** + * 获取验证码 + */ + CaptchaInfo getCaptcha(); + + /** + * 刷新令牌 + * + * @param refreshToken 刷新令牌 + * @return 认证令牌 + */ + AuthenticationToken refreshToken(String refreshToken); +} diff --git a/src/main/java/com/youlai/boot/auth/service/WxMaAuthService.java b/src/main/java/com/youlai/boot/auth/service/WxMaAuthService.java new file mode 100644 index 0000000..4e8db97 --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/service/WxMaAuthService.java @@ -0,0 +1,53 @@ +package com.youlai.boot.auth.service; + +import com.youlai.boot.auth.model.WxMaLoginResp; +import com.youlai.boot.framework.security.model.AuthenticationToken; + +/** + * 微信小程序认证服务接口 + * + * @author Ray.Hao + * @since 2.4.0 + */ +public interface WxMaAuthService { + + /** + * 静默登录 + *

+ * 通过微信登录凭证(code)获取用户唯一标识(openid), + * 如果用户已绑定手机号则直接登录成功,否则返回需绑定手机号的提示。 + *

+ * + * @param code 微信登录凭证(wx.login 获取) + * @return 登录结果(成功返回 token,需绑定返回 openid) + */ + WxMaLoginResp silentLogin(String code); + + /** + * 手机号快捷登录 + *

+ * 同时使用微信登录凭证和手机号授权凭证, + * 一步完成用户注册/登录,无需额外绑定流程。 + * 适用于企业认证的小程序(已开通手机号快捷登录权限)。 + *

+ * + * @param loginCode 微信登录凭证(wx.login 获取) + * @param phoneCode 手机号授权凭证(getPhoneNumber 事件获取) + * @return 认证令牌 + */ + AuthenticationToken phoneLogin(String loginCode, String phoneCode); + + /** + * 绑定手机号 + *

+ * 为已静默登录但未绑定手机号的用户绑定手机号, + * 绑定成功后自动完成登录。 + *

+ * + * @param openid 微信用户唯一标识 + * @param mobile 手机号码 + * @param smsCode 短信验证码 + * @return 认证令牌 + */ + AuthenticationToken bindMobile(String openid, String mobile, String smsCode); +} 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 new file mode 100644 index 0000000..086caa1 --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/service/impl/AuthServiceImpl.java @@ -0,0 +1,151 @@ +package com.youlai.boot.auth.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.youlai.boot.auth.service.AuthService; +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.framework.security.util.SecurityUtils; +import com.youlai.boot.framework.integration.sms.enums.SmsTypeEnum; +import com.youlai.boot.framework.integration.sms.service.SmsService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * 认证服务实现类 + * + * @author Ray.Hao + * @since 2.4.0 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class AuthServiceImpl implements AuthService { + + private final AuthenticationManager authenticationManager; + private final TokenManager tokenManager; + + private final SmsService smsService; + private final RedisTemplate redisTemplate; + private final CaptchaService captchaService; + + /** + * 用户名密码登录 + * + * @param username 用户名 + * @param password 密码 + * @return 访问令牌 + */ + @Override + public AuthenticationToken login(String username, String password) { + // 1. 创建用于密码认证的令牌(未认证) + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(username.trim(), password); + + // 2. 执行认证(认证中) + // 说明:这里的认证流程由 Spring Security 提供的 AuthenticationManager 执行。 + // 默认情况下会委托给 DaoAuthenticationProvider: + // 1) retrieveUser(...):内部通过 UserDetailsService.loadUserByUsername(...) 获取用户信息(本项目为 SysUserDetailsService 实现) + // 2) additionalAuthenticationChecks(...):对比请求密码与用户存储密码(由 PasswordEncoder 完成匹配) + // 认证通过后返回已认证的 Authentication(principal 为 SysUserDetails,authorities 为角色/权限集合)。 + Authentication authentication = authenticationManager.authenticate(authenticationToken); + + // 3. 认证成功后生成 JWT 令牌,并存入 Security 上下文,供登录日志 AOP 使用(已认证) + AuthenticationToken authenticationTokenResponse = + tokenManager.generateToken(authentication); + SecurityContextHolder.getContext().setAuthentication(authentication); + return authenticationTokenResponse; + } + + /** + * 发送登录短信验证码 + * + * @param mobile 手机号 + */ + @Override + public void sendSmsCode(String mobile) { + + // 随机生成4位验证码 + // String code = String.valueOf((int) ((Math.random() * 9 + 1) * 1000)); + // TODO 为了方便测试,验证码固定为 1234,实际开发中在配置了厂商短信服务后,可以使用上面的随机验证码 + String code = "1234"; + + // 发送短信验证码 + Map templateParams = new HashMap<>(); + templateParams.put("code", code); + try { + smsService.sendSms(mobile, SmsTypeEnum.LOGIN, templateParams); + } catch (Exception e) { + log.error("发送短信验证码失败", e); + } + // 缓存验证码至Redis,用于登录校验 + redisTemplate.opsForValue().set(StrUtil.format(RedisConstants.Captcha.SMS_LOGIN_CODE, mobile), code, 5, TimeUnit.MINUTES); + } + + /** + * 短信验证码登录 + * + * @param mobile 手机号 + * @param code 验证码 + * @return 访问令牌 + */ + @Override + public AuthenticationToken loginBySms(String mobile, String code) { + // 1. 创建用户短信验证码认证的令牌(未认证) + SmsAuthenticationToken smsAuthenticationToken = new SmsAuthenticationToken(mobile, code); + + // 2. 执行认证(认证中) + Authentication authentication = authenticationManager.authenticate(smsAuthenticationToken); + + // 3. 认证成功后生成 JWT 令牌,并存入 Security 上下文,供登录日志 AOP 使用(已认证) + AuthenticationToken authenticationToken = tokenManager.generateToken(authentication); + SecurityContextHolder.getContext().setAuthentication(authentication); + return authenticationToken; + } + + /** + * 注销登录 + */ + @Override + public void logout() { + String token = SecurityUtils.getAccessToken(); + if (StrUtil.isNotBlank(token)) { + tokenManager.invalidateToken(token); + // 清除Security上下文 + SecurityContextHolder.clearContext(); + } + } + + /** + * 获取验证码 + */ + @Override + public CaptchaInfo getCaptcha() { + return captchaService.generate(); + } + + /** + * 刷新token + * + * @param refreshToken 刷新令牌 + * @return 新的访问令牌 + */ + @Override + public AuthenticationToken refreshToken(String refreshToken) { + return tokenManager.refreshToken(refreshToken); + } + +} 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 new file mode 100644 index 0000000..02d85bb --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/service/impl/WxMaAuthServiceImpl.java @@ -0,0 +1,245 @@ +package com.youlai.boot.auth.service.impl; + +import cn.binarywang.wx.miniapp.api.WxMaService; +import cn.binarywang.wx.miniapp.bean.WxMaJscode2SessionResult; +import cn.binarywang.wx.miniapp.bean.WxMaPhoneNumberInfo; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import com.youlai.boot.auth.model.WxMaLoginResp; +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.token.TokenManager; +import com.youlai.boot.system.enums.SocialPlatformEnum; +import com.youlai.boot.system.model.entity.SysUser; +import com.youlai.boot.system.service.UserSocialService; +import com.youlai.boot.system.service.UserService; +import com.youlai.boot.system.service.UserRoleService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Collections; + +/** + * 微信小程序认证服务实现 + * + * @author Ray.Hao + * @since 4.0.0 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class WxMaAuthServiceImpl implements WxMaAuthService { + + private final WxMaService wxMaService; + private final AuthenticationManager authenticationManager; + private final TokenManager tokenManager; + private final UserService userService; + private final UserSocialService userSocialService; + private final UserRoleService userRoleService; + private final RedisTemplate redisTemplate; + + /** + * 静默登录 + */ + @Override + public WxMaLoginResp silentLogin(String code) { + WxMaAuthenticationToken token = new WxMaAuthenticationToken(code); + + try { + Authentication authentication = authenticationManager.authenticate(token); + AuthenticationToken authToken = tokenManager.generateToken(authentication); + SecurityContextHolder.getContext().setAuthentication(authentication); + return WxMaLoginResp.builder() + .isNewUser(false) + .needBindMobile(false) + .accessToken(authToken.getAccessToken()) + .refreshToken(authToken.getRefreshToken()) + .tokenType(authToken.getTokenType()) + .expiresIn(authToken.getExpiresIn()) + .build(); + } catch (NeedBindMobileException e) { + return WxMaLoginResp.builder() + .isNewUser(true) + .needBindMobile(true) + .openid(e.getOpenid()) + .build(); + } + } + + /** + * 手机号快捷登录 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public AuthenticationToken phoneLogin(String loginCode, String phoneCode) { + // 1. 解析微信登录凭证,获取会话信息 + WxMaJscode2SessionResult session = resolveSession(loginCode); + String openid = session.getOpenid(); + + // 2. 解析手机号授权凭证,获取手机号 + String mobile = resolvePhoneNumber(phoneCode); + + log.info("微信小程序手机号快捷登录:openid={}, mobile={}", openid, mobile); + + // 3. 查询或创建用户 + SysUser user = findOrCreateUser(mobile); + + // 4. 绑定微信 openid + bindWechatOpenid(user, session); + + // 5. 生成认证令牌 + return generateAuthToken(mobile); + } + + /** + * 绑定手机号 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public AuthenticationToken bindMobile(String openid, String mobile, String smsCode) { + // 1. 验证短信验证码 + validateSmsCode(mobile, smsCode); + + // 2. 查询或创建用户 + SysUser user = findOrCreateUser(mobile); + + // 3. 绑定微信 openid + userSocialService.bindOrUpdate( + user.getId(), + SocialPlatformEnum.WECHAT_MINI, + openid, + null, null, null, null + ); + + log.info("微信小程序绑定手机号成功:mobile={}, openid={}", mobile, openid); + + // 4. 生成认证令牌 + return generateAuthToken(mobile); + } + + // ==================== 私有方法 ==================== + + /** + * 解析微信登录凭证,获取会话信息 + */ + private WxMaJscode2SessionResult resolveSession(String loginCode) { + try { + return wxMaService.jsCode2SessionInfo(loginCode); + } catch (Exception e) { + log.error("获取微信会话信息失败,loginCode={}", loginCode, e); + throw new IllegalArgumentException("微信登录失败:" + e.getMessage()); + } + } + + /** + * 解析手机号授权凭证,获取手机号 + */ + private String resolvePhoneNumber(String phoneCode) { + try { + WxMaPhoneNumberInfo phoneInfo = wxMaService.getUserService().getPhoneNoInfo(phoneCode); + return phoneInfo.getPhoneNumber(); + } catch (Exception e) { + log.error("获取微信手机号失败,phoneCode={}", phoneCode, e); + throw new IllegalArgumentException("获取手机号失败:" + e.getMessage()); + } + } + + /** + * 查询或创建用户 + */ + private SysUser findOrCreateUser(String mobile) { + SysUser user = userService.lambdaQuery() + .eq(SysUser::getMobile, mobile) + .one(); + + if (user == null) { + user = createNewUser(mobile); + log.info("微信小程序登录:创建新用户,mobile={}, userId={}", mobile, user.getId()); + } + + return user; + } + + /** + * 创建新用户 + *

+ * 新用户默认分配 GUEST(访问游客)角色 + *

+ */ + private SysUser createNewUser(String mobile) { + SysUser user = new SysUser(); + user.setMobile(mobile); + user.setUsername("wx_" + IdUtil.fastSimpleUUID().substring(0, 8)); + user.setNickname("微信用户"); + user.setStatus(1); + user.setIsDeleted(0); + user.setCreateTime(LocalDateTime.now()); + user.setUpdateTime(LocalDateTime.now()); + userService.save(user); + + // 分配 GUEST 角色(角色ID=3) + userRoleService.saveUserRoles(user.getId(), Collections.singletonList(3L)); + + return user; + } + + /** + * 绑定微信 openid + */ + private void bindWechatOpenid(SysUser user, WxMaJscode2SessionResult session) { + try { + userSocialService.bindOrUpdate( + user.getId(), + SocialPlatformEnum.WECHAT_MINI, + session.getOpenid(), + session.getUnionid(), + user.getNickname(), + user.getAvatar(), + session.getSessionKey() + ); + } catch (Exception e) { + // 绑定失败不影响登录 + log.warn("绑定微信 openid 失败,userId={}, openid={}", user.getId(), session.getOpenid(), e); + } + } + + /** + * 验证短信验证码 + */ + private void validateSmsCode(String mobile, String smsCode) { + String cacheKey = StrUtil.format(RedisConstants.Captcha.SMS_LOGIN_CODE, mobile); + String cachedCode = (String) redisTemplate.opsForValue().get(cacheKey); + + if (!StrUtil.equals(smsCode, cachedCode)) { + throw new IllegalArgumentException("验证码错误"); + } + + // 验证成功后删除验证码 + redisTemplate.delete(cacheKey); + } + + /** + * 生成认证令牌 + */ + private AuthenticationToken generateAuthToken(String mobile) { + SysUserDetails userDetails = new SysUserDetails(userService.getAuthInfoByMobile(mobile)); + Authentication authentication = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities() + ); + AuthenticationToken authToken = tokenManager.generateToken(authentication); + SecurityContextHolder.getContext().setAuthentication(authentication); + return authToken; + } +} diff --git a/src/main/java/com/youlai/boot/codegen/config/CodegenProperties.java b/src/main/java/com/youlai/boot/codegen/config/CodegenProperties.java new file mode 100644 index 0000000..5f32799 --- /dev/null +++ b/src/main/java/com/youlai/boot/codegen/config/CodegenProperties.java @@ -0,0 +1,98 @@ +package com.youlai.boot.codegen.config; + +import cn.hutool.core.io.file.FileNameUtil; +import cn.hutool.core.map.MapUtil; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +/** + * 代码生成配置属性 + * + * @author Ray + * @since 2.11.0 + */ +@Component +@EnableConfigurationProperties(CodegenProperties.class) +@ConfigurationProperties(prefix = "codegen") +@Data +public class CodegenProperties { + + + /** + * 默认配置 + */ + private DefaultConfig defaultConfig ; + + /** + * 模板配置 + */ + private Map templateConfigs = MapUtil.newHashMap(true); + + /** + * 后端应用名 + */ + private String backendAppName; + + /** + * 前端应用名 + */ + private String frontendAppName; + + /** + * 下载文件名 + */ + private String downloadFileName; + + /** + * 排除数据表 + */ + private List excludeTables; + + /** + * 模板配置 + */ + @Data + public static class TemplateConfig { + + /** + * 模板路径 (e.g. /templates/codegen/controller.java.vm) + */ + private String templatePath; + + /** + * 子包名 (e.g. controller/service/mapper/model) + */ + private String subpackageName; + + /** + * 文件扩展名,如 .java + */ + private String extension = FileNameUtil.EXT_JAVA; + + } + + /** + * 默认配置 + */ + @Data + public static class DefaultConfig { + + /** + * 作者 (e.g. Ray) + */ + private String author; + + /** + * 默认模块名(e.g. system) + */ + private String moduleName; + + } + + +} diff --git a/src/main/java/com/youlai/boot/codegen/controller/CodegenController.java b/src/main/java/com/youlai/boot/codegen/controller/CodegenController.java new file mode 100644 index 0000000..7e589f1 --- /dev/null +++ b/src/main/java/com/youlai/boot/codegen/controller/CodegenController.java @@ -0,0 +1,112 @@ +package com.youlai.boot.codegen.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.common.result.PageResult; +import com.youlai.boot.common.result.Result; +import com.youlai.boot.codegen.config.CodegenProperties; +import com.youlai.boot.common.enums.ActionTypeEnum; +import com.youlai.boot.common.enums.LogModuleEnum; +import com.youlai.boot.codegen.service.CodegenService; +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.codegen.service.GenTableService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * 代码生成器控制层 + * + * @author Ray + * @since 2.10.0 + */ +@Tag(name = "11.代码生成") +@RestController +@RequestMapping("/api/v1/codegen") +@RequiredArgsConstructor +@Slf4j +public class CodegenController { + + private final CodegenService codegenService; + private final GenTableService genTableService; + private final CodegenProperties codegenProperties; + + @Operation(summary = "获取数据表分页列表") + @GetMapping("/table") + public PageResult getTablePage( + TableQuery queryParams + ) { + Page result = codegenService.getTablePage(queryParams); + return PageResult.success(result); + } + + @Operation(summary = "获取代码生成配置") + @GetMapping("/{tableName}/config") + public Result getGenTableFormData( + @Parameter(description = "表名", example = "sys_user") @PathVariable String tableName + ) { + GenConfigForm formData = genTableService.getGenTableFormData(tableName); + return Result.success(formData); + } + + @Operation(summary = "保存代码生成配置") + @PostMapping("/{tableName}/config") + @Log(module = LogModuleEnum.CODEGEN, value = ActionTypeEnum.UPDATE) + public Result saveGenConfig(@RequestBody GenConfigForm formData) { + genTableService.saveGenConfig(formData); + return Result.success(); + } + + @Operation(summary = "删除代码生成配置") + @DeleteMapping("/{tableName}/config") + public Result deleteGenConfig( + @Parameter(description = "表名", example = "sys_user") @PathVariable String tableName + ) { + genTableService.deleteGenConfig(tableName); + return Result.success(); + } + + @Operation(summary = "获取预览生成代码") + @GetMapping("/{tableName}/preview") + public Result> getTablePreviewData(@PathVariable String tableName, + @RequestParam(value = "pageType", required = false, defaultValue = "classic") String pageType, + @RequestParam(value = "type", required = false, defaultValue = "ts") String type) { + List list = codegenService.getCodegenPreviewData(tableName, pageType, type); + return Result.success(list); + } + + @Operation(summary = "下载代码") + @GetMapping("/{tableName}/download") + @Log(module = LogModuleEnum.CODEGEN, value = ActionTypeEnum.DOWNLOAD) + public void downloadZip(HttpServletResponse response, @PathVariable String tableName, + @RequestParam(value = "pageType", required = false, defaultValue = "classic") String pageType, + @RequestParam(value = "type", required = false, defaultValue = "ts") String type) { + String[] tableNames = tableName.split(","); + byte[] data = codegenService.downloadCode(tableNames, pageType, type); + + response.reset(); + response.setHeader("Content-Disposition", "attachment; filename=" + URLEncoder.encode(codegenProperties.getDownloadFileName(), StandardCharsets.UTF_8)); + response.setContentType("application/octet-stream; charset=UTF-8"); + + try (ServletOutputStream outputStream = response.getOutputStream()) { + outputStream.write(data); + outputStream.flush(); + } catch (IOException e) { + log.error("Error while writing the zip file1 to response", e); + throw new RuntimeException("Failed to write the zip file1 to response", e); + } + } +} diff --git a/src/main/java/com/youlai/boot/codegen/converter/CodegenConverter.java b/src/main/java/com/youlai/boot/codegen/converter/CodegenConverter.java new file mode 100644 index 0000000..0ac2038 --- /dev/null +++ b/src/main/java/com/youlai/boot/codegen/converter/CodegenConverter.java @@ -0,0 +1,41 @@ +package com.youlai.boot.codegen.converter; + +import com.youlai.boot.codegen.model.entity.GenTable; +import com.youlai.boot.codegen.model.entity.GenTableColumn; +import com.youlai.boot.codegen.model.form.GenConfigForm; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import java.util.List; + +/** + * 代码生成配置转换器 + * + * @author Ray + * @since 2.10.0 + */ +@Mapper(componentModel = "spring") +public interface CodegenConverter { + + @Mapping(source = "genTable.tableName", target = "tableName") + @Mapping(source = "genTable.businessName", target = "businessName") + @Mapping(source = "genTable.moduleName", target = "moduleName") + @Mapping(source = "genTable.packageName", target = "packageName") + @Mapping(source = "genTable.entityName", target = "entityName") + @Mapping(source = "genTable.author", target = "author") + @Mapping(source = "genTable.pageType", target = "pageType") + @Mapping(source = "genTable.removeTablePrefix", target = "removeTablePrefix") + @Mapping(source = "fieldConfigs", target = "fieldConfigs") + GenConfigForm toGenConfigForm(GenTable genTable, List fieldConfigs); + + List toGenTableColumnForm(List fieldConfigs); + + GenConfigForm.FieldConfig toGenTableColumnForm(GenTableColumn genTableColumn); + + GenTable toGenTable(GenConfigForm formData); + + List toGenTableColumn(List fieldConfigs); + + GenTableColumn toGenTableColumn(GenConfigForm.FieldConfig fieldConfig); + +} diff --git a/src/main/java/com/youlai/boot/codegen/enums/FormTypeEnum.java b/src/main/java/com/youlai/boot/codegen/enums/FormTypeEnum.java new file mode 100644 index 0000000..f006f92 --- /dev/null +++ b/src/main/java/com/youlai/boot/codegen/enums/FormTypeEnum.java @@ -0,0 +1,89 @@ +package com.youlai.boot.codegen.enums; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import com.youlai.boot.common.base.IBaseEnum; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 表单类型枚举 + * + * @author Ray + * @since 2.10.0 + */ +@Getter +@RequiredArgsConstructor +public enum FormTypeEnum implements IBaseEnum { + + /** + * 输入框 + */ + INPUT(1, "输入框"), + + /** + * 下拉框 + */ + SELECT(2, "下拉框"), + + /** + * 单选框 + */ + RADIO(3, "单选框"), + + /** + * 复选框 + */ + CHECK_BOX(4, "复选框"), + + /** + * 数字输入框 + */ + INPUT_NUMBER(5, "数字输入框"), + + /** + * 开关 + */ + SWITCH(6, "开关"), + + /** + * 文本域 + */ + TEXT_AREA(7, "文本域"), + + /** + * 日期时间框 + */ + DATE(8, "日期框"), + + /** + * 日期框 + */ + DATE_TIME(9, "日期时间框"), + + /** + * 隐藏域 + */ + HIDDEN(10, "隐藏域"); + + + // Mybatis-Plus 提供注解表示插入数据库时插入该值 + @EnumValue + @JsonValue + private final Integer value; + + // @JsonValue // 表示对枚举序列化时返回此字段 + private final String label; + + + @JsonCreator + public static FormTypeEnum fromValue(Integer value) { + for (FormTypeEnum type : FormTypeEnum.values()) { + if (type.getValue().equals(value)) { + return type; + } + } + throw new IllegalArgumentException("No enum constant with value " + value); + } +} diff --git a/src/main/java/com/youlai/boot/codegen/enums/JavaTypeEnum.java b/src/main/java/com/youlai/boot/codegen/enums/JavaTypeEnum.java new file mode 100644 index 0000000..0ee49a1 --- /dev/null +++ b/src/main/java/com/youlai/boot/codegen/enums/JavaTypeEnum.java @@ -0,0 +1,108 @@ +package com.youlai.boot.codegen.enums; + +import lombok.Getter; + +import java.util.HashMap; +import java.util.Map; + +/** + * 表单类型枚举 + * + * @author Ray + * @since 2.10.0 + */ +@Getter +public enum JavaTypeEnum { + + VARCHAR("varchar", "String", "string"), + CHAR("char", "String", "string"), + BLOB("blob", "byte[]", "Uint8Array"), + TEXT("text", "String", "string"), + JSON("json", "String", "any"), + INTEGER("int", "Integer", "number"), + TINYINT("tinyint", "Integer", "number"), + SMALLINT("smallint", "Integer", "number"), + MEDIUMINT("mediumint", "Integer", "number"), + BIGINT("bigint", "Long", "number"), + FLOAT("float", "Float", "number"), + DOUBLE("double", "Double", "number"), + DECIMAL("decimal", "BigDecimal", "number"), + DATE("date", "LocalDate", "string"), + DATETIME("datetime", "LocalDateTime", "string"), + TIMESTAMP("timestamp", "LocalDateTime", "string"), + BOOLEAN("boolean", "Boolean", "boolean"), + BIT("bit", "Boolean", "boolean"); + + // 数据库类型 + private final String dbType; + // Java类型 + private final String javaType; + // TypeScript类型 + private final String tsType; + + // 数据库类型和Java类型的映射 + private static final Map typeMap = new HashMap<>(); + + // 初始化映射关系 + static { + for (JavaTypeEnum javaTypeEnum : JavaTypeEnum.values()) { + typeMap.put(javaTypeEnum.getDbType(), javaTypeEnum); + } + } + + JavaTypeEnum(String dbType, String javaType, String tsType) { + this.dbType = dbType; + this.javaType = javaType; + this.tsType = tsType; + } + + /** + * 根据数据库类型获取对应的Java类型 + * + * @param columnType 列类型 + * @return 对应的Java类型 + */ + public static String getJavaTypeByColumnType(String columnType) { + String normalized = normalizeColumnType(columnType); + JavaTypeEnum javaTypeEnum = typeMap.get(normalized); + if (javaTypeEnum != null) { + return javaTypeEnum.getJavaType(); + } + return "String"; + } + + /** + * 根据Java类型获取对应的TypeScript类型 + * + * @param javaType Java类型 + * @return 对应的TypeScript类型 + */ + public static String getTsTypeByJavaType(String javaType) { + if (javaType == null) { + return "any"; + } + for (JavaTypeEnum javaTypeEnum : JavaTypeEnum.values()) { + if (javaTypeEnum.getJavaType().equals(javaType)) { + return javaTypeEnum.getTsType(); + } + } + return "any"; + } + + private static String normalizeColumnType(String columnType) { + if (columnType == null) { + return ""; + } + // Handle values like: varchar(255), bigint unsigned, INT + String normalized = columnType.trim().toLowerCase(); + int parenIndex = normalized.indexOf('('); + if (parenIndex > -1) { + normalized = normalized.substring(0, parenIndex); + } + // Remove modifiers + normalized = normalized.replace("unsigned", "").replace("zerofill", "").trim(); + // Collapse repeated spaces + normalized = normalized.replaceAll("\\s+", " "); + return normalized; + } +} diff --git a/src/main/java/com/youlai/boot/codegen/enums/QueryTypeEnum.java b/src/main/java/com/youlai/boot/codegen/enums/QueryTypeEnum.java new file mode 100644 index 0000000..fcdff5f --- /dev/null +++ b/src/main/java/com/youlai/boot/codegen/enums/QueryTypeEnum.java @@ -0,0 +1,73 @@ +package com.youlai.boot.codegen.enums; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import com.youlai.boot.common.base.IBaseEnum; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 查询类型枚举 + * + * @author Ray + * @since 2.10.0 + */ +@Getter +@RequiredArgsConstructor +public enum QueryTypeEnum implements IBaseEnum { + + /** 等于 */ + EQ(1, "="), + + /** 模糊匹配 */ + LIKE(2, "LIKE '%s%'"), + + /** 包含 */ + IN(3, "IN"), + + /** 范围 */ + BETWEEN(4, "BETWEEN"), + + /** 大于 */ + GT(5, ">"), + + /** 大于等于 */ + GE(6, ">="), + + /** 小于 */ + LT(7, "<"), + + /** 小于等于 */ + LE(8, "<="), + + /** 不等于 */ + NE(9, "!="), + + /** 左模糊匹配 */ + LIKE_LEFT(10, "LIKE '%s'"), + + /** 右模糊匹配 */ + LIKE_RIGHT(11, "LIKE 's%'"); + + + // 存储在数据库中的枚举属性值 + @EnumValue + @JsonValue + private final Integer value; + + // 序列化成 JSON 时的属性值 + private final String label; + + + @JsonCreator + public static QueryTypeEnum fromValue(Integer value) { + for (QueryTypeEnum type : QueryTypeEnum.values()) { + if (type.getValue().equals(value)) { + return type; + } + } + throw new IllegalArgumentException("No enum constant with value " + value); + } + +} diff --git a/src/main/java/com/youlai/boot/codegen/mapper/DatabaseMapper.java b/src/main/java/com/youlai/boot/codegen/mapper/DatabaseMapper.java new file mode 100644 index 0000000..1e370d1 --- /dev/null +++ b/src/main/java/com/youlai/boot/codegen/mapper/DatabaseMapper.java @@ -0,0 +1,41 @@ +package com.youlai.boot.codegen.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.codegen.model.vo.ColumnMetaVO; +import com.youlai.boot.codegen.model.vo.TableMetaVO; +import com.youlai.boot.codegen.model.query.TableQuery; +import com.youlai.boot.codegen.model.vo.TablePageVO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + + +/** + * 数据库映射层 + * + * @author Ray + * @since 2.9.0 + */ +@Mapper +public interface DatabaseMapper extends BaseMapper { + + /** + * 获取表分页列表 + * + * @param page + * @param queryParams + * @return + */ + Page getTablePage(Page page, TableQuery queryParams); + + /** + * 获取表字段列表 + * + * @param tableName + * @return + */ + List getTableColumns(String tableName); + + TableMetaVO getTableMetadata(String tableName); +} diff --git a/src/main/java/com/youlai/boot/codegen/mapper/GenTableColumnMapper.java b/src/main/java/com/youlai/boot/codegen/mapper/GenTableColumnMapper.java new file mode 100644 index 0000000..cf56327 --- /dev/null +++ b/src/main/java/com/youlai/boot/codegen/mapper/GenTableColumnMapper.java @@ -0,0 +1,20 @@ +package com.youlai.boot.codegen.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.youlai.boot.codegen.model.entity.GenTableColumn; +import org.apache.ibatis.annotations.Mapper; + +/** + * 代码生成表字段配置访问层 + * + * @author Ray + * @since 2.10.0 + */ +@Mapper +public interface GenTableColumnMapper extends BaseMapper { + +} + + + + diff --git a/src/main/java/com/youlai/boot/codegen/mapper/GenTableMapper.java b/src/main/java/com/youlai/boot/codegen/mapper/GenTableMapper.java new file mode 100644 index 0000000..245e30f --- /dev/null +++ b/src/main/java/com/youlai/boot/codegen/mapper/GenTableMapper.java @@ -0,0 +1,20 @@ +package com.youlai.boot.codegen.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.youlai.boot.codegen.model.entity.GenTable; +import org.apache.ibatis.annotations.Mapper; + +/** + * 代码生成表配置访问层 + * + * @author Ray + * @since 2.10.0 + */ +@Mapper +public interface GenTableMapper extends BaseMapper { + +} + + + + 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 new file mode 100644 index 0000000..51f5f28 --- /dev/null +++ b/src/main/java/com/youlai/boot/codegen/model/entity/GenTable.java @@ -0,0 +1,65 @@ +package com.youlai.boot.codegen.model.entity; + +import com.baomidou.mybatisplus.annotation.*; + +import com.youlai.boot.common.base.BaseEntity; +import lombok.Getter; +import lombok.Setter; + +/** + * 代码生成表配置 + * + * @author Ray + * @since 2.10.0 + */ +@TableName(value = "gen_table") +@Getter +@Setter +public class GenTable extends BaseEntity { + + /** + * 表名 + */ + private String tableName; + + /** + * 包名 + */ + private String packageName; + + /** + * 模块名 + */ + private String moduleName; + + /** + * 实体类名 + */ + private String entityName; + + /** + * 业务名 + */ + private String businessName; + + /** + * 父菜单ID + */ + private Long parentMenuId; + + /** + * 作者 + */ + private String author; + + /** + * 页面类型 classic|curd + */ + private String pageType; + + /** + * 要移除的表前缀,如: sys_ + */ + private String removeTablePrefix; +} + 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 new file mode 100644 index 0000000..a264074 --- /dev/null +++ b/src/main/java/com/youlai/boot/codegen/model/entity/GenTableColumn.java @@ -0,0 +1,107 @@ +package com.youlai.boot.codegen.model.entity; + +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.codegen.enums.FormTypeEnum; +import com.youlai.boot.codegen.enums.QueryTypeEnum; +import lombok.Getter; +import lombok.Setter; + +/** + * 代码生成表字段配置实体 + * + * @author Ray + * @since 2.10.0 + */ +@TableName(value = "gen_table_column") +@Getter +@Setter +public class GenTableColumn extends BaseEntity { + + + /** + * 关联的表配置ID + */ + private Long tableId; + + /** + * 列名 + */ + private String columnName; + + /** + * 列类型 + */ + private String columnType; + + /** + * 字段长度 + */ + private Long maxLength; + + /** + * 字段名称 + */ + private String fieldName; + + /** + * 字段排序 + */ + private Integer fieldSort; + + /** + * 字段类型 + */ + private String fieldType; + + /** + * 字段描述 + */ + private String fieldComment; + + /** + * 表单类型 + */ + private FormTypeEnum formType; + + /** + * 查询方式 + */ + private QueryTypeEnum queryType; + + /** + * 是否在列表显示 + */ + private Integer isShowInList; + + /** + * 是否在表单显示 + */ + private Integer isShowInForm; + + /** + * 是否在查询条件显示 + */ + private Integer isShowInQuery; + + /** + * 是否必填 + */ + private Integer isRequired; + + /** + * TypeScript类型 + */ + @TableField(exist = false) + @JsonIgnore + private String tsType; + + /** + * 字典类型 + */ + private String dictType; +} + diff --git a/src/main/java/com/youlai/boot/codegen/model/form/GenConfigForm.java b/src/main/java/com/youlai/boot/codegen/model/form/GenConfigForm.java new file mode 100644 index 0000000..5e584e0 --- /dev/null +++ b/src/main/java/com/youlai/boot/codegen/model/form/GenConfigForm.java @@ -0,0 +1,109 @@ +package com.youlai.boot.codegen.model.form; + +import com.youlai.boot.codegen.enums.FormTypeEnum; +import com.youlai.boot.codegen.enums.QueryTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +/** + * 代码生成配置表单 + * + * @author Ray + * @since 2.10.0 + */ +@Schema(description = "代码生成配置表单") +@Data +public class GenConfigForm { + + @Schema(description = "主键",example = "1") + private Long id; + + @Schema(description = "表名",example = "sys_user") + private String tableName; + + @Schema(description = "业务名",example = "用户") + private String businessName; + + @Schema(description = "模块名",example = "system") + private String moduleName; + + @Schema(description = "包名",example = "com.youlai") + private String packageName; + + @Schema(description = "实体名",example = "User") + private String entityName; + + @Schema(description = "作者",example = "youlaitech") + private String author; + + @Schema(description = "上级菜单ID",example = "1") + private Long parentMenuId; + + @Schema(description = "字段配置列表") + private List fieldConfigs; + + @Schema(description = "后端应用名") + private String backendAppName; + + @Schema(description = "前端应用名") + private String frontendAppName; + + @Schema(description = "页面类型 classic|curd", example = "classic") + private String pageType; + + @Schema(description = "要移除的表前缀,如: sys_", example = "sys_") + private String removeTablePrefix; + + @Schema(description = "字段配置") + @Data + public static class FieldConfig { + + @Schema(description = "主键") + private Long id; + + @Schema(description = "列名") + private String columnName; + + @Schema(description = "列类型") + private String columnType; + + @Schema(description = "字段名") + private String fieldName; + + @Schema(description = "字段排序") + private Integer fieldSort; + + @Schema(description = "字段类型") + private String fieldType; + + @Schema(description = "字段描述") + private String fieldComment; + + @Schema(description = "是否在列表显示") + private Integer isShowInList; + + @Schema(description = "是否在表单显示") + private Integer isShowInForm; + + @Schema(description = "是否在查询条件显示") + private Integer isShowInQuery; + + @Schema(description = "是否必填") + private Integer isRequired; + + @Schema(description = "最大长度") + private Integer maxLength; + + @Schema(description = "表单类型") + private FormTypeEnum formType; + + @Schema(description = "查询类型") + private QueryTypeEnum queryType; + + @Schema(description = "字典类型") + private String dictType; + + } +} diff --git a/src/main/java/com/youlai/boot/codegen/model/query/TablePageQuery.java b/src/main/java/com/youlai/boot/codegen/model/query/TablePageQuery.java new file mode 100644 index 0000000..51b88c9 --- /dev/null +++ b/src/main/java/com/youlai/boot/codegen/model/query/TablePageQuery.java @@ -0,0 +1,31 @@ +package com.youlai.boot.codegen.model.query; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.youlai.boot.common.base.BaseQuery; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +/** + * 数据表分页查询对象 + * + * @author Ray + * @since 2.10.0 + */ +@Schema(description = "数据表分页查询对象") +@Getter +@Setter +public class TablePageQuery extends BaseQuery { + + @Schema(description="关键字(表名)") + private String keywords; + + /** + * 排除的表名 + */ + @JsonIgnore + private List excludeTables; + +} diff --git a/src/main/java/com/youlai/boot/codegen/model/query/TableQuery.java b/src/main/java/com/youlai/boot/codegen/model/query/TableQuery.java new file mode 100644 index 0000000..031aa2c --- /dev/null +++ b/src/main/java/com/youlai/boot/codegen/model/query/TableQuery.java @@ -0,0 +1,31 @@ +package com.youlai.boot.codegen.model.query; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.youlai.boot.common.base.BaseQuery; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +/** + * 数据表查询对象 + * + * @author Ray + * @since 2.10.0 + */ +@Schema(description = "数据表查询对象") +@Getter +@Setter +public class TableQuery extends BaseQuery { + + @Schema(description="关键字(表名)") + private String keywords; + + /** + * 排除的表名 + */ + @JsonIgnore + private List excludeTables; + +} diff --git a/src/main/java/com/youlai/boot/codegen/model/vo/CodegenPreviewVO.java b/src/main/java/com/youlai/boot/codegen/model/vo/CodegenPreviewVO.java new file mode 100644 index 0000000..a0384a0 --- /dev/null +++ b/src/main/java/com/youlai/boot/codegen/model/vo/CodegenPreviewVO.java @@ -0,0 +1,25 @@ +package com.youlai.boot.codegen.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "代码生成代码预览Vo") +@Data +public class CodegenPreviewVO { + + @Schema(description = "生成文件路径") + private String path; + + @Schema(description = "生成文件名称",example = "SysUser.java" ) + private String fileName; + + @Schema(description = "生成文件内容") + private String content; + + @Schema(description = "文件范围(frontend/backend)") + private String scope; + + @Schema(description = "文件语言(扩展名)") + private String language; + +} diff --git a/src/main/java/com/youlai/boot/codegen/model/vo/ColumnMetaVO.java b/src/main/java/com/youlai/boot/codegen/model/vo/ColumnMetaVO.java new file mode 100644 index 0000000..c5ba382 --- /dev/null +++ b/src/main/java/com/youlai/boot/codegen/model/vo/ColumnMetaVO.java @@ -0,0 +1,26 @@ +package com.youlai.boot.codegen.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "数据表字段元数据") +@Data +public class ColumnMetaVO { + + private String columnName; + + private String dataType; + + private String columnComment; + + private Long characterMaximumLength; + + private Integer isPrimaryKey; + + private String isNullable; + + private String characterSetName; + + private String collationName; + +} diff --git a/src/main/java/com/youlai/boot/codegen/model/vo/TableMetaVO.java b/src/main/java/com/youlai/boot/codegen/model/vo/TableMetaVO.java new file mode 100644 index 0000000..7ad0be5 --- /dev/null +++ b/src/main/java/com/youlai/boot/codegen/model/vo/TableMetaVO.java @@ -0,0 +1,26 @@ +package com.youlai.boot.codegen.model.vo; + +import lombok.Data; + +/** + * 数据表元数据 + * + * @author Ray + * @since 2.10.0 + */ +@Data +public class TableMetaVO { + + private String tableName; + + private String tableComment; + + private String tableCollation; + + private String engine; + + private String charset; + + private String createTime; + +} diff --git a/src/main/java/com/youlai/boot/codegen/model/vo/TablePageVO.java b/src/main/java/com/youlai/boot/codegen/model/vo/TablePageVO.java new file mode 100644 index 0000000..3d482f5 --- /dev/null +++ b/src/main/java/com/youlai/boot/codegen/model/vo/TablePageVO.java @@ -0,0 +1,32 @@ +package com.youlai.boot.codegen.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + + +@Schema(description = "表视图对象") +@Data +public class TablePageVO { + + @Schema(description = "表名称", example = "sys_user") + private String tableName; + + @Schema(description = "表描述",example = "用户表") + private String tableComment; + + @Schema(description = "表排序规则",example = "utf8mb4_general_ci") + private String tableCollation; + + @Schema(description = "存储引擎",example = "InnoDB") + private String engine; + + @Schema(description = "字符集",example = "utf8mb4") + private String charset; + + @Schema(description = "创建时间",example = "2023-08-08 08:08:08") + private String createTime; + + @Schema(description="是否已配置") + private Integer isConfigured; + +} diff --git a/src/main/java/com/youlai/boot/codegen/service/CodegenService.java b/src/main/java/com/youlai/boot/codegen/service/CodegenService.java new file mode 100644 index 0000000..9e8ebb7 --- /dev/null +++ b/src/main/java/com/youlai/boot/codegen/service/CodegenService.java @@ -0,0 +1,40 @@ +package com.youlai.boot.codegen.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +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 java.util.List; + +/** + * 代码生成配置接口 + * + * @author Ray + * @since 2.10.0 + */ +public interface CodegenService { + + /** + * 获取数据表分页列表 + * + * @param queryParams 查询参数 + * @return + */ + Page getTablePage(TableQuery queryParams); + + /** + * 获取预览生成代码 + * + * @param tableName 表名 + * @return + */ + List getCodegenPreviewData(String tableName, String pageType, String type); + + /** + * 下载代码 + * @param tableNames 表名 + * @return + */ + byte[] downloadCode(String[] tableNames, String pageType, String type); +} diff --git a/src/main/java/com/youlai/boot/codegen/service/GenTableColumnService.java b/src/main/java/com/youlai/boot/codegen/service/GenTableColumnService.java new file mode 100644 index 0000000..c175a8d --- /dev/null +++ b/src/main/java/com/youlai/boot/codegen/service/GenTableColumnService.java @@ -0,0 +1,14 @@ +package com.youlai.boot.codegen.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.youlai.boot.codegen.model.entity.GenTableColumn; + +/** + * 代码生成配置接口 + * + * @author Ray + * @since 2.10.0 + */ +public interface GenTableColumnService extends IService { + +} diff --git a/src/main/java/com/youlai/boot/codegen/service/GenTableService.java b/src/main/java/com/youlai/boot/codegen/service/GenTableService.java new file mode 100644 index 0000000..44dbaa7 --- /dev/null +++ b/src/main/java/com/youlai/boot/codegen/service/GenTableService.java @@ -0,0 +1,39 @@ +package com.youlai.boot.codegen.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.youlai.boot.codegen.model.entity.GenTable; +import com.youlai.boot.codegen.model.form.GenConfigForm; + +/** + * 代码生成配置接口 + * + * @author Ray + * @since 2.10.0 + */ +public interface GenTableService extends IService { + + /** + * 获取代码生成配置 + * + * @param tableName 表名 + * @return + */ + GenConfigForm getGenTableFormData(String tableName); + + /** + * 保存代码生成配置 + * + * @param formData 表单数据 + * @return + */ + void saveGenConfig(GenConfigForm formData); + + /** + * 删除代码生成配置 + * + * @param tableName 表名 + * @return + */ + void deleteGenConfig(String tableName); + +} diff --git a/src/main/java/com/youlai/boot/codegen/service/impl/CodegenServiceImpl.java b/src/main/java/com/youlai/boot/codegen/service/impl/CodegenServiceImpl.java new file mode 100644 index 0000000..ee6be50 --- /dev/null +++ b/src/main/java/com/youlai/boot/codegen/service/impl/CodegenServiceImpl.java @@ -0,0 +1,427 @@ +package com.youlai.boot.codegen.service.impl; + +import cn.hutool.core.collection.CollectionUtil; +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.io.file.FileNameUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.template.Template; +import cn.hutool.extra.template.TemplateConfig; +import cn.hutool.extra.template.TemplateEngine; +import cn.hutool.extra.template.TemplateUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.codegen.enums.JavaTypeEnum; +import com.youlai.boot.codegen.config.CodegenProperties; +import com.youlai.boot.codegen.service.GenTableService; +import com.youlai.boot.codegen.service.GenTableColumnService; +import com.youlai.boot.codegen.service.CodegenService; +import com.youlai.boot.common.exception.BusinessException; +import com.youlai.boot.codegen.mapper.DatabaseMapper; +import com.youlai.boot.codegen.model.entity.GenTable; +import com.youlai.boot.codegen.model.entity.GenTableColumn; +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 lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * 代码生成服务实现类。 + * + *

+ * 根据代码生成配置({@link CodegenProperties})与表/字段元数据,渲染模板并提供预览与下载能力。 + *

+ * + * @author Ray + * @since 2.10.0 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class CodegenServiceImpl implements CodegenService { + + private final DatabaseMapper databaseMapper; + private final CodegenProperties codegenProperties; + private final GenTableService genTableService; + private final GenTableColumnService genTableColumnService; + + /** + * 数据表分页列表 + * + * @param queryParams 查询参数 + * @return 分页结果 + */ + public Page getTablePage(TableQuery queryParams) { + Page page = new Page<>(queryParams.getPageNum(), queryParams.getPageSize()); + // 设置排除的表 + List excludeTables = codegenProperties.getExcludeTables(); + queryParams.setExcludeTables(excludeTables); + + return databaseMapper.getTablePage(page, queryParams); + } + + /** + * 解析前端模板路径 + * + * @param templateName 模板标识 + * @param templateConfig 模板配置 + * @param frontendType 前端类型 + * @return 模板路径 + */ + private String resolveFrontendTemplatePath(String templateName, + CodegenProperties.TemplateConfig templateConfig, + String frontendType) { + if (!"js".equals(frontendType)) { + return templateConfig.getTemplatePath(); + } + if ("API".equals(templateName)) { + return "codegen/frontend/js/api.js.vm"; + } + if ("VIEW".equals(templateName)) { + return "codegen/frontend/js/index.js.vue.vm"; + } + return templateConfig.getTemplatePath(); + } + + /** + * 解析前端文件后缀 + * + * @param templateName 模板标识 + * @param templateConfig 模板配置 + * @param frontendType 前端类型 + * @return 文件后缀 + */ + private String resolveFrontendExtension(String templateName, + CodegenProperties.TemplateConfig templateConfig, + String frontendType) { + if (!"js".equals(frontendType)) { + return templateConfig.getExtension(); + } + if ("API".equals(templateName) || "API_TYPES".equals(templateName)) { + return ".js"; + } + return templateConfig.getExtension(); + } + + /** + * 获取预览生成代码 + * + * @param tableName 表名 + * @return 预览数据 + */ + @Override + public List getCodegenPreviewData(String tableName, String pageType, String type) { + + List list = new ArrayList<>(); + + GenTable genTable = genTableService.getOne(new LambdaQueryWrapper() + .eq(GenTable::getTableName, tableName) + ); + if (genTable == null) { + throw new BusinessException("未找到表生成配置"); + } + + List fieldConfigs = genTableColumnService.list(new LambdaQueryWrapper() + .eq(GenTableColumn::getTableId, genTable.getId()) + .orderByAsc(GenTableColumn::getFieldSort) + + ); + if (CollectionUtil.isEmpty(fieldConfigs)) { + throw new BusinessException("未找到字段生成配置"); + } + + // 遍历模板配置 + Map templateConfigs = codegenProperties.getTemplateConfigs(); + String frontendType = StrUtil.blankToDefault(type, "ts").toLowerCase(); + for (Map.Entry templateConfigEntry : templateConfigs.entrySet()) { + CodegenPreviewVO previewVo = new CodegenPreviewVO(); + + CodegenProperties.TemplateConfig templateConfig = templateConfigEntry.getValue(); + + String templateName = templateConfigEntry.getKey(); + if ("js".equals(frontendType) && "API_TYPES".equals(templateName)) { + continue; + } + + String effectiveTemplatePath = resolveFrontendTemplatePath(templateName, templateConfig, frontendType); + String extension = resolveFrontendExtension(templateName, templateConfig, frontendType); + + /* 1. 生成文件名 UserController */ + // User Role Menu Dept + String entityName = genTable.getEntityName(); + // Controller Service Mapper Entity + // .java .ts .vue + + // 文件名 UserController.java + String fileName = getFileName(entityName, templateName, extension); + previewVo.setFileName(fileName); + previewVo.setScope(resolveScope(templateName)); + previewVo.setLanguage(resolveLanguage(fileName)); + + /* 2. 生成文件路径 */ + // 包名:com.youlai.boot + String packageName = genTable.getPackageName(); + // 模块名:system + String moduleName = genTable.getModuleName(); + // 子包名:controller + String subpackageName = templateConfig.getSubpackageName(); + // 组合成文件路径:src/main/java/com/youlai/boot/system/controller + String filePath = getFilePath(templateName, moduleName, packageName, subpackageName, entityName); + previewVo.setPath(filePath); + + /* 3. 生成文件内容 */ + // 将模板文件中的变量替换为具体的值 生成代码内容 + // 优先使用保存的 ui,没有则使用请求参数 + String finalType = StrUtil.blankToDefault(genTable.getPageType(), pageType); + String content = getCodeContent( + effectiveTemplatePath, + templateConfig.getSubpackageName(), + genTable, + fieldConfigs, + finalType + ); + previewVo.setContent(content); + + list.add(previewVo); + } + return list; + } + + private String resolveScope(String templateName) { + return switch (templateName) { + case "API", "API_TYPES", "VIEW" -> "frontend"; + default -> "backend"; + }; + } + + private String resolveLanguage(String fileName) { + return FileNameUtil.extName(fileName).toLowerCase(); + } + + /** + * 生成文件名。 + * + *

部分模板需要使用约定的命名规则(例如前端 API 文件)。

+ * + * @param entityName 实体名(例如 User) + * @param templateName 模板名(例如 Entity、Controller、API) + * @param extension 文件后缀(例如 .java、.ts) + * @return 文件名 + */ + private String getFileName(String entityName, String templateName, String extension) { + if ("Entity".equals(templateName)) { + return entityName + extension; + } else if ("MapperXml".equals(templateName)) { + return entityName + "Mapper" + extension; + } else if ("API".equals(templateName)) { + return "index" + extension; + } else if ("API_TYPES".equals(templateName)) { + return "types" + extension; + } else if ("VIEW".equals(templateName)) { + return "index.vue"; + } + return entityName + templateName + extension; + } + + /** + * 生成文件路径。 + * + * @param templateName 模板名 + * @param moduleName 模块名(例如 system) + * @param packageName 包名(例如 com.youlai.boot) + * @param subPackageName 子包名(例如 controller、service.impl、api、views) + * @param entityName 实体名(例如 User) + * @return 生成文件路径 + */ + private String getFilePath(String templateName, String moduleName, String packageName, String subPackageName, String entityName) { + String path; + if ("MapperXml".equals(templateName)) { + path = (codegenProperties.getBackendAppName() + + File.separator + + "src" + File.separator + "main" + File.separator + "resources" + + File.separator + subPackageName + + File.separator + moduleName + ); + } else if ("API".equals(templateName)) { + path = (codegenProperties.getFrontendAppName() + + File.separator + "src" + + File.separator + "api" + + File.separator + moduleName + + File.separator + StrUtil.toSymbolCase(entityName, '-') + ); + } else if ("API_TYPES".equals(templateName)) { + path = (codegenProperties.getFrontendAppName() + + File.separator + "src" + + File.separator + "api" + + File.separator + moduleName + + File.separator + StrUtil.toSymbolCase(entityName, '-') + ); + } else if ("VIEW".equals(templateName)) { + // path = "src/views/system/user"; + path = (codegenProperties.getFrontendAppName() + + File.separator + "src" + + File.separator + subPackageName + + File.separator + moduleName + + File.separator + StrUtil.toSymbolCase(entityName, '-') + ); + } else { + path = (codegenProperties.getBackendAppName() + + File.separator + + "src" + File.separator + "main" + File.separator + "java" + + File.separator + packageName + + File.separator + moduleName + + File.separator + subPackageName + ); + } + + // subPackageName = model.entity => model/entity + path = path.replace(".", File.separator); + + return path; + } + + /** + * 渲染模板,生成代码内容。 + * + * @param templateConfig 模板配置 + * @param genTable 表生成配置 + * @param fieldConfigs 字段配置 + * @param pageType 前端页面类型 + * @return 渲染后的代码内容 + */ + private String getCodeContent(String templatePath, + String subpackageName, + GenTable genTable, + List fieldConfigs, + String pageType) { + + Map bindMap = new HashMap<>(); + + String entityName = genTable.getEntityName(); + + bindMap.put("packageName", genTable.getPackageName()); + bindMap.put("moduleName", genTable.getModuleName()); + bindMap.put("subpackageName", subpackageName); + bindMap.put("date", DateUtil.format(new Date(), "yyyy-MM-dd HH:mm")); + bindMap.put("entityName", entityName); + bindMap.put("tableName", genTable.getTableName()); + bindMap.put("author", genTable.getAuthor()); + String entityLowerCamel = StrUtil.lowerFirst(entityName); + String entityKebab = StrUtil.toSymbolCase(entityName, '-'); + String entityUpperSnake = StrUtil.toSymbolCase(entityName, '_').toUpperCase(); + bindMap.put("entityLowerCamel", entityLowerCamel); + bindMap.put("entityKebab", entityKebab); + bindMap.put("entityUpperSnake", entityUpperSnake); + bindMap.put("businessName", genTable.getBusinessName()); + bindMap.put("fieldConfigs", fieldConfigs); + + boolean hasLocalDateTime = false; + boolean hasBigDecimal = false; + boolean hasRequiredField = false; + + for (GenTableColumn fieldConfig : fieldConfigs) { + + if (StrUtil.isBlank(fieldConfig.getFieldType())) { + fieldConfig.setFieldType(JavaTypeEnum.getJavaTypeByColumnType(fieldConfig.getColumnType())); + } + + if ("LocalDateTime".equals(fieldConfig.getFieldType()) || "LocalDate".equals(fieldConfig.getFieldType())) { + hasLocalDateTime = true; + } + if ("BigDecimal".equals(fieldConfig.getFieldType())) { + hasBigDecimal = true; + } + if (ObjectUtil.equals(fieldConfig.getIsRequired(), 1)) { + hasRequiredField = true; + } + fieldConfig.setTsType(JavaTypeEnum.getTsTypeByJavaType(fieldConfig.getFieldType())); + } + + bindMap.put("hasLocalDateTime", hasLocalDateTime); + bindMap.put("hasBigDecimal", hasBigDecimal); + bindMap.put("hasRequiredField", hasRequiredField); + + TemplateEngine templateEngine = TemplateUtil.createEngine(new TemplateConfig("templates", TemplateConfig.ResourceMode.CLASSPATH)); + // 根据 ui 选择不同的前端页面模板:默认 index.vue.vm;封装版使用 index.curd.vue.vm + String path = templatePath; + if ("curd".equalsIgnoreCase(pageType)) { + if (path.endsWith("index.js.vue.vm")) { + path = path.replace("index.js.vue.vm", "index.curd.js.vue.vm"); + } else if (path.endsWith("index.vue.vm")) { + path = path.replace("index.vue.vm", "index.curd.vue.vm"); + } + } + Template template = templateEngine.getTemplate(path); + + return template.render(bindMap); + } + + /** + * 下载代码。 + * + * @param tableNames 表名数组,支持多张表 + * @param ui 页面类型 + * @return zip 压缩文件字节数组 + */ + @Override + public byte[] downloadCode(String[] tableNames, String ui, String type) { + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ZipOutputStream zip = new ZipOutputStream(outputStream)) { + + // 遍历每个表名,生成对应的代码并压缩到 zip 文件中 + for (String tableName : tableNames) { + generateAndZipCode(tableName, zip, ui, type); + } + // 确保所有压缩数据写入输出流,避免数据残留在内存缓冲区引发的数据不完整 + zip.finish(); + return outputStream.toByteArray(); + + } catch (IOException e) { + log.error("Error while generating zip for code download", e); + throw new RuntimeException("Failed to generate code zip file1", e); + } + } + + /** + * 根据表名生成代码并压缩到 zip 文件中。 + * + * @param tableName 表名 + * @param zip 压缩文件输出流 + * @param ui 页面类型 + */ + private void generateAndZipCode(String tableName, ZipOutputStream zip, String ui, String type) { + List codePreviewList = getCodegenPreviewData(tableName, ui, type); + + for (CodegenPreviewVO codePreview : codePreviewList) { + String fileName = codePreview.getFileName(); + String content = codePreview.getContent(); + String path = codePreview.getPath(); + + try { + // 创建压缩条目 + ZipEntry zipEntry = new ZipEntry(path + File.separator + fileName); + zip.putNextEntry(zipEntry); + + // 写入文件内容 + zip.write(content.getBytes(StandardCharsets.UTF_8)); + + // 关闭当前压缩条目 + zip.closeEntry(); + + } catch (IOException e) { + log.error("Error while adding file1 {} to zip", fileName, e); + } + } + } + +} diff --git a/src/main/java/com/youlai/boot/codegen/service/impl/GenTableColumnServiceImpl.java b/src/main/java/com/youlai/boot/codegen/service/impl/GenTableColumnServiceImpl.java new file mode 100644 index 0000000..34b8229 --- /dev/null +++ b/src/main/java/com/youlai/boot/codegen/service/impl/GenTableColumnServiceImpl.java @@ -0,0 +1,21 @@ +package com.youlai.boot.codegen.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.youlai.boot.codegen.mapper.GenTableColumnMapper; +import com.youlai.boot.codegen.model.entity.GenTableColumn; +import com.youlai.boot.codegen.service.GenTableColumnService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * 代码生成字段配置服务实现类 + * + * @author Ray.Hao + * @since 2.10.0 + */ +@Service +@RequiredArgsConstructor +public class GenTableColumnServiceImpl extends ServiceImpl implements GenTableColumnService { + + +} diff --git a/src/main/java/com/youlai/boot/codegen/service/impl/GenTableServiceImpl.java b/src/main/java/com/youlai/boot/codegen/service/impl/GenTableServiceImpl.java new file mode 100644 index 0000000..6df5b3d --- /dev/null +++ b/src/main/java/com/youlai/boot/codegen/service/impl/GenTableServiceImpl.java @@ -0,0 +1,227 @@ +package com.youlai.boot.codegen.service.impl; + +import cn.hutool.core.collection.CollectionUtil; +import cn.hutool.core.lang.Assert; +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.YouLaiBootApplication; +import com.youlai.boot.common.enums.EnvEnum; +import com.youlai.boot.codegen.enums.FormTypeEnum; +import com.youlai.boot.codegen.enums.JavaTypeEnum; +import com.youlai.boot.codegen.enums.QueryTypeEnum; +import com.youlai.boot.common.exception.BusinessException; +import com.youlai.boot.codegen.config.CodegenProperties; +import com.youlai.boot.codegen.converter.CodegenConverter; +import com.youlai.boot.codegen.mapper.DatabaseMapper; +import com.youlai.boot.codegen.mapper.GenTableMapper; +import com.youlai.boot.codegen.model.vo.ColumnMetaVO; +import com.youlai.boot.codegen.model.vo.TableMetaVO; +import com.youlai.boot.codegen.model.entity.GenTable; +import com.youlai.boot.codegen.model.entity.GenTableColumn; +import com.youlai.boot.codegen.model.form.GenConfigForm; +import com.youlai.boot.codegen.service.GenTableService; +import com.youlai.boot.codegen.service.GenTableColumnService; +import com.youlai.boot.system.service.MenuService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +/** + * 数据库服务实现类 + * + * @author Ray + * @since 2.10.0 + */ +@Service +@RequiredArgsConstructor +public class GenTableServiceImpl extends ServiceImpl implements GenTableService { + + private final DatabaseMapper databaseMapper; + private final CodegenProperties codegenProperties; + private final GenTableColumnService genTableColumnService; + private final CodegenConverter codegenConverter; + + @Value("${spring.profiles.active}") + private String springProfilesActive; + + private final MenuService menuService; + + /** + * 获取代码生成配置 + * + * @param tableName 表名 eg: sys_user + * @return 代码生成配置 + */ + @Override + public GenConfigForm getGenTableFormData(String tableName) { + // 查询表生成配置 + GenTable genTable = this.getOne( + new LambdaQueryWrapper<>(GenTable.class) + .eq(GenTable::getTableName, tableName) + .last("LIMIT 1") + ); + + // 是否有代码生成配置 + boolean hasGenTable = genTable != null; + + // 如果没有代码生成配置,则根据表的元数据生成默认配置 + if (genTable == null) { + TableMetaVO tableMetadata = databaseMapper.getTableMetadata(tableName); + Assert.isTrue(tableMetadata != null, "未找到表元数据"); + + genTable = new GenTable(); + genTable.setTableName(tableName); + + // 表注释作为业务名称,去掉表字 例如:用户表 -> 用户 + String tableComment = tableMetadata.getTableComment(); + if (StrUtil.isNotBlank(tableComment)) { + genTable.setBusinessName(tableComment.replace("表", "").trim()); + } + // 根据表名生成实体类名,支持去除前缀 例如:sys_user -> SysUser + String removePrefix = genTable.getRemoveTablePrefix(); + String processedTable = tableName; + if (StrUtil.isNotBlank(removePrefix) && StrUtil.startWith(tableName, removePrefix)) { + processedTable = StrUtil.removePrefix(tableName, removePrefix); + } + genTable.setEntityName(StrUtil.toCamelCase(StrUtil.upperFirst(StrUtil.toCamelCase(processedTable)))); + + genTable.setPackageName(YouLaiBootApplication.class.getPackageName()); + genTable.setModuleName(codegenProperties.getDefaultConfig().getModuleName()); // 默认模块名 + genTable.setAuthor(codegenProperties.getDefaultConfig().getAuthor()); + } + + // 根据表的列 + 已经存在的字段生成配置 得到 组合后的字段生成配置 + List genTableColumns = new ArrayList<>(); + + // 获取表的列 + List tableColumns = databaseMapper.getTableColumns(tableName); + if (CollectionUtil.isNotEmpty(tableColumns)) { + // 查询字段生成配置 + List fieldConfigList = genTableColumnService.list( + new LambdaQueryWrapper() + .eq(GenTableColumn::getTableId, genTable.getId()) + .orderByAsc(GenTableColumn::getFieldSort) + ); + Integer maxSort = fieldConfigList.stream() + .map(GenTableColumn::getFieldSort) + .filter(Objects::nonNull) // 过滤掉空值 + .max(Integer::compareTo) + .orElse(0); + for (ColumnMetaVO tableColumn : tableColumns) { + // 根据列名获取字段生成配置 + String columnName = tableColumn.getColumnName(); + GenTableColumn fieldConfig = fieldConfigList.stream() + .filter(item -> StrUtil.equals(item.getColumnName(), columnName)) + .findFirst() + .orElseGet(() -> createDefaultFieldConfig(tableColumn)); + if (fieldConfig.getFieldSort() == null) { + fieldConfig.setFieldSort(++maxSort); + } + // 根据列类型设置字段类型 + String fieldType = fieldConfig.getFieldType(); + if (StrUtil.isBlank(fieldType)) { + String javaType = JavaTypeEnum.getJavaTypeByColumnType(fieldConfig.getColumnType()); + fieldConfig.setFieldType(javaType); + } + // 如果没有代码生成配置,则默认展示在列表和表单 + if (!hasGenTable) { + fieldConfig.setIsShowInList(1); + fieldConfig.setIsShowInForm(1); + } + genTableColumns.add(fieldConfig); + } + } + // 对 genTableColumns 按照 fieldSort 排序 + genTableColumns = genTableColumns.stream().sorted(Comparator.comparing(GenTableColumn::getFieldSort)).toList(); + GenConfigForm genConfigForm = codegenConverter.toGenConfigForm(genTable, genTableColumns); + + genConfigForm.setFrontendAppName(codegenProperties.getFrontendAppName()); + genConfigForm.setBackendAppName(codegenProperties.getBackendAppName()); + return genConfigForm; + } + + + /** + * 创建默认字段配置 + * + * @param columnMetaVO 表字段元数据 + * @return + */ + private GenTableColumn createDefaultFieldConfig(ColumnMetaVO columnMetaVO) { + GenTableColumn fieldConfig = new GenTableColumn(); + fieldConfig.setColumnName(columnMetaVO.getColumnName()); + fieldConfig.setColumnType(columnMetaVO.getDataType()); + fieldConfig.setFieldComment(columnMetaVO.getColumnComment()); + fieldConfig.setFieldName(StrUtil.toCamelCase(columnMetaVO.getColumnName())); + fieldConfig.setIsRequired("YES".equals(columnMetaVO.getIsNullable()) ? 0 : 1); + + String columnType = StrUtil.blankToDefault(fieldConfig.getColumnType(), "").toLowerCase(); + if ("date".equals(columnType)) { + fieldConfig.setFormType(FormTypeEnum.DATE); + } else if ("datetime".equals(columnType) || "timestamp".equals(columnType)) { + fieldConfig.setFormType(FormTypeEnum.DATE_TIME); + } else { + fieldConfig.setFormType(FormTypeEnum.INPUT); + } + + fieldConfig.setQueryType(QueryTypeEnum.EQ); + fieldConfig.setMaxLength(columnMetaVO.getCharacterMaximumLength()); + return fieldConfig; + } + + /** + * 保存代码生成配置 + * + * @param formData 代码生成配置表单 + */ + @Override + public void saveGenConfig(GenConfigForm formData) { + GenTable genTable = codegenConverter.toGenTable(formData); + this.saveOrUpdate(genTable); + + // 如果选择上级菜单且当前环境不是生产环境,则保存菜单 + Long parentMenuId = formData.getParentMenuId(); + if (parentMenuId != null && !EnvEnum.PROD.getValue().equals(springProfilesActive)) { + menuService.addMenuForCodegen(parentMenuId, genTable); + } + + List genTableColumns = codegenConverter.toGenTableColumn(formData.getFieldConfigs()); + + if (CollectionUtil.isEmpty(genTableColumns)) { + throw new BusinessException("字段配置不能为空"); + } + genTableColumns.forEach(genTableColumn -> { + genTableColumn.setTableId(genTable.getId()); + }); + genTableColumnService.saveOrUpdateBatch(genTableColumns); + } + + /** + * 删除代码生成配置 + * + * @param tableName 表名 + */ + @Override + public void deleteGenConfig(String tableName) { + GenTable genTable = this.getOne(new LambdaQueryWrapper() + .eq(GenTable::getTableName, tableName)); + + boolean result = this.remove(new LambdaQueryWrapper() + .eq(GenTable::getTableName, tableName) + ); + if (result) { + genTableColumnService.remove(new LambdaQueryWrapper() + .eq(GenTableColumn::getTableId, genTable.getId()) + ); + } + } + + + +} diff --git a/src/main/java/com/youlai/boot/common/annotation/DataPermission.java b/src/main/java/com/youlai/boot/common/annotation/DataPermission.java new file mode 100644 index 0000000..10f0d00 --- /dev/null +++ b/src/main/java/com/youlai/boot/common/annotation/DataPermission.java @@ -0,0 +1,28 @@ +package com.youlai.boot.common.annotation; + +import java.lang.annotation.*; + +/** + * 数据权限注解 + * + * @author zc + * @since 2.0.0 + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface DataPermission { + + /** + * 数据权限 {@link com.baomidou.mybatisplus.extension.plugins.inner.DataPermissionInterceptor} + */ + String deptAlias() default ""; + + String deptIdColumnName() default "dept_id"; + + String userAlias() default ""; + + 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/common/annotation/Log.java new file mode 100644 index 0000000..a9caa74 --- /dev/null +++ b/src/main/java/com/youlai/boot/common/annotation/Log.java @@ -0,0 +1,47 @@ +package com.youlai.boot.common.annotation; + +import com.youlai.boot.common.enums.ActionTypeEnum; +import com.youlai.boot.common.enums.LogModuleEnum; + +import java.lang.annotation.*; + +/** + * 日志注解 + * + * @author Ray + * @since 2024/6/25 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +@Documented +public @interface Log { + + /** + * 模块 + * + * @return 模块 + */ + LogModuleEnum module(); + + /** + * 操作类型 + * + * @return 操作类型 + */ + ActionTypeEnum value(); + + /** + * 操作标题(可选,默认使用枚举描述) + * + * @return 标题 + */ + String title() default ""; + + /** + * 自定义日志内容(可选,用于记录操作细节) + * + * @return 日志内容 + */ + String content() default ""; + +} diff --git a/src/main/java/com/youlai/boot/common/annotation/RepeatSubmit.java b/src/main/java/com/youlai/boot/common/annotation/RepeatSubmit.java new file mode 100644 index 0000000..053f132 --- /dev/null +++ b/src/main/java/com/youlai/boot/common/annotation/RepeatSubmit.java @@ -0,0 +1,27 @@ +package com.youlai.boot.common.annotation; + + +import java.lang.annotation.*; + +/** + * 防止重复提交注解 + *

+ * 该注解用于方法上,防止在指定时间内的重复提交。 默认时间为5秒。 + * + * @author Ray.Hao + * @since 2.3.0 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +public @interface RepeatSubmit { + + /** + * 锁过期时间(秒) + *

+ * 默认5秒内不允许重复提交 + */ + int expire() default 5; + +} diff --git a/src/main/java/com/youlai/boot/common/annotation/ValidField.java b/src/main/java/com/youlai/boot/common/annotation/ValidField.java new file mode 100644 index 0000000..559b06e --- /dev/null +++ b/src/main/java/com/youlai/boot/common/annotation/ValidField.java @@ -0,0 +1,35 @@ +package com.youlai.boot.common.annotation; + +import com.youlai.boot.common.validator.FieldValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +/** + * 用于验证字段值是否合法的注解 + * + * @author Ray.Hao + * @since 2.18.0 + */ +@Documented +@Constraint(validatedBy = FieldValidator.class) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidField { + + /** + * 验证失败时的错误信息。 + */ + String message() default "非法字段"; + + Class[] groups() default {}; + + Class[] payload() default {}; + + /** + * 允许的合法值列表。 + */ + String[] allowedValues(); + +} diff --git a/src/main/java/com/youlai/boot/common/aspect/LogAspect.java b/src/main/java/com/youlai/boot/common/aspect/LogAspect.java new file mode 100644 index 0000000..145341c --- /dev/null +++ b/src/main/java/com/youlai/boot/common/aspect/LogAspect.java @@ -0,0 +1,144 @@ +package com.youlai.boot.common.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.common.enums.ActionTypeEnum; +import com.youlai.boot.common.enums.LogModuleEnum; +import com.youlai.boot.common.util.IPUtils; +import com.youlai.boot.framework.security.util.SecurityUtils; +import com.youlai.boot.system.model.entity.SysLog; +import com.youlai.boot.system.service.LogService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.time.LocalDateTime; + +/** + * 日志切面 + * + * @author Ray.Hao + * @since 2.10.0 + */ +@Aspect +@Component +@RequiredArgsConstructor +@Slf4j +public class LogAspect { + + private final LogService logService; + + /** + * 日志注解切点 + */ + @Pointcut("@annotation(logAnnotation)") + public void logPointCut(Log logAnnotation) { + } + + /** + * 环绕通知:记录操作日志 + */ + @Around(value = "logPointCut(logAnnotation)", argNames = "pjp,logAnnotation") + public Object around(ProceedingJoinPoint pjp, Log logAnnotation) throws Throwable { + + long startTime = System.currentTimeMillis(); + // 在方法执行前获取用户信息,避免 logout 等操作清除 SecurityContext 后无法获取 + Long userId = SecurityUtils.getUserId(); + String username = SecurityUtils.getUsername(); + Object result = null; + Exception exception = null; + + try { + result = pjp.proceed(); + return result; + } catch (Exception e) { + exception = e; + throw e; + } finally { + long executionTime = System.currentTimeMillis() - startTime; + // fallback:登录等场景在 proceed() 前未认证,需在 proceed() 后获取 + if (userId == null) { + userId = SecurityUtils.getUserId(); + username = SecurityUtils.getUsername(); + } + try { + saveLogAsync(logAnnotation, executionTime, exception, userId, username); + } catch (Exception ex) { + log.error("保存操作日志失败", ex); + } + } + } + + /** + * 异步保存日志 + */ + @Async + public void saveLogAsync(Log logAnnotation, long executionTime, Exception exception, Long userId, String username) { + try { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + return; + } + + HttpServletRequest request = attributes.getRequest(); + + // 解析 User-Agent + String userAgentStr = request.getHeader("User-Agent"); + UserAgent userAgent = UserAgentUtil.parse(userAgentStr); + + // 解析 IP 地区 + String ip = IPUtils.getIpAddr(request); + String region = IPUtils.getRegion(ip); + String province = null; + String city = null; + if (StrUtil.isNotBlank(region)) { + String[] parts = region.split("\\|"); + if (parts.length >= 3) { + province = StrUtil.blankToDefault(parts[2], null); + city = StrUtil.blankToDefault(parts[3], null); + } + } + + // 构建日志实体 + LogModuleEnum module = logAnnotation.module(); + ActionTypeEnum actionType = logAnnotation.value(); + String title = StrUtil.blankToDefault(logAnnotation.title(), + module.getLabel() + "-" + actionType.getLabel()); + String content = logAnnotation.content(); + + SysLog logEntity = new SysLog(); + logEntity.setModule(module); + logEntity.setActionType(actionType); + logEntity.setTitle(title); + logEntity.setContent(content); + logEntity.setOperatorId(userId); + logEntity.setOperatorName(username); + logEntity.setRequestUri(request.getRequestURI()); + logEntity.setRequestMethod(request.getMethod()); + logEntity.setIp(ip); + logEntity.setProvince(province); + logEntity.setCity(city); + logEntity.setDevice(userAgent.getOs().getName()); + logEntity.setOs(userAgent.getOs().getName()); + logEntity.setBrowser(userAgent.getBrowser().getName()); + logEntity.setStatus(exception == null ? 1 : 0); + logEntity.setErrorMsg(exception != null ? exception.getMessage() : null); + logEntity.setExecutionTime((int) executionTime); + logEntity.setCreateTime(LocalDateTime.now()); + + logService.save(logEntity); + } catch (Exception e) { + log.error("保存操作日志异常: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/com/youlai/boot/common/aspect/RepeatSubmitAspect.java b/src/main/java/com/youlai/boot/common/aspect/RepeatSubmitAspect.java new file mode 100644 index 0000000..97011be --- /dev/null +++ b/src/main/java/com/youlai/boot/common/aspect/RepeatSubmitAspect.java @@ -0,0 +1,102 @@ +package com.youlai.boot.common.aspect; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.digest.DigestUtil; +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.common.util.IPUtils; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.util.concurrent.TimeUnit; + +/** + * 防重复提交切面 + * + * @author Ray.Hao + * @since 2.3.0 + */ +@Aspect +@Component +@RequiredArgsConstructor +@Slf4j +public class RepeatSubmitAspect { + + private final RedissonClient redissonClient; + + /** + * 防重复提交切点 + */ + @Pointcut("@annotation(repeatSubmit)") + public void repeatSubmitPointCut(RepeatSubmit repeatSubmit) { + } + + /** + * 环绕通知:处理防重复提交逻辑 + */ + @Around(value = "repeatSubmitPointCut(repeatSubmit)", argNames = "pjp,repeatSubmit") + public Object handleRepeatSubmit(ProceedingJoinPoint pjp, RepeatSubmit repeatSubmit) throws Throwable { + String lockKey = buildLockKey(); + + int expire = repeatSubmit.expire(); + RLock lock = redissonClient.getLock(lockKey); + + boolean locked = lock.tryLock(0, expire, TimeUnit.SECONDS); + if (!locked) { + throw new BusinessException(ResultCode.DUPLICATE_SUBMISSION); + } + return pjp.proceed(); + } + + /** + * 生成防重复提交锁的 key + * @return 锁的 key + */ + private String buildLockKey() { + 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); + } + + /** + * 获取用户唯一标识 + * 1. 从请求头中获取 Token,使用 SHA-256 加密 Token 作为用户唯一标识 + * 2. 如果 Token 为空,使用 IP 作为用户唯一标识 + * + * @param request 请求对象 + * @return 用户唯一标识 + */ + private String getUserIdentifier(HttpServletRequest request) { + // 用户身份唯一标识 + String userIdentifier; + // 从请求头中获取 Token + String tokenHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + if (StrUtil.isNotBlank(tokenHeader) && tokenHeader.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) { + String rawToken = tokenHeader.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length()); // 去掉 Bearer 后的 Token + userIdentifier = DigestUtil.sha256Hex(rawToken); // 使用 SHA-256 加密 Token 作为用户唯一标识 + } else { + userIdentifier = IPUtils.getIpAddr(request); // 使用 IP 作为用户唯一标识 + } + return userIdentifier; + } + + +} + diff --git a/src/main/java/com/youlai/boot/common/base/BaseEntity.java b/src/main/java/com/youlai/boot/common/base/BaseEntity.java new file mode 100644 index 0000000..9c6a281 --- /dev/null +++ b/src/main/java/com/youlai/boot/common/base/BaseEntity.java @@ -0,0 +1,48 @@ +package com.youlai.boot.common.base; + +import com.baomidou.mybatisplus.annotation.*; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 基础实体类 + * + *

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

+ * + * @author Ray + * @since 2024/6/23 + */ +@Data +public class BaseEntity implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + @JsonInclude(value = JsonInclude.Include.NON_NULL) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @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/common/base/BaseQuery.java b/src/main/java/com/youlai/boot/common/base/BaseQuery.java new file mode 100644 index 0000000..026d967 --- /dev/null +++ b/src/main/java/com/youlai/boot/common/base/BaseQuery.java @@ -0,0 +1,33 @@ +package com.youlai.boot.common.base; + +import com.youlai.boot.common.annotation.ValidField; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +@Data +@Schema +public class BaseQuery implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "页码", requiredMode = Schema.RequiredMode.NOT_REQUIRED, example = "1") + private Integer pageNum = 1; + + @Schema(description = "每页记录数", requiredMode = Schema.RequiredMode.NOT_REQUIRED, example = "10") + private Integer pageSize = 10; + + @Schema(description = "排序字段", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @ValidField(allowedValues = {"create_time", "update_time"}) + private String sortBy; + + @Schema(description = "排序方式(正序:ASC;反序:DESC)", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + private String order; + + public boolean isPaged() { + return pageNum != null && pageSize != null && pageSize > 0; + } +} diff --git a/src/main/java/com/youlai/boot/common/base/IBaseEnum.java b/src/main/java/com/youlai/boot/common/base/IBaseEnum.java new file mode 100644 index 0000000..0a44cd8 --- /dev/null +++ b/src/main/java/com/youlai/boot/common/base/IBaseEnum.java @@ -0,0 +1,88 @@ +package com.youlai.boot.common.base; + + +import cn.hutool.core.util.ObjectUtil; + +import java.util.EnumSet; +import java.util.Objects; + +/** + * 枚举通用接口 + * + * @author haoxr + * @since 2022/3/27 12:06 + */ +public interface IBaseEnum { + + T getValue(); + + String getLabel(); + + /** + * 根据值获取枚举 + * + * @param value + * @param clazz + * @param 枚举 + * @return + */ + static & IBaseEnum> E getEnumByValue(Object value, Class clazz) { + Objects.requireNonNull(value); + EnumSet allEnums = EnumSet.allOf(clazz); // 获取类型下的所有枚举 + E matchEnum = allEnums.stream() + .filter(e -> ObjectUtil.equal(e.getValue(), value)) + .findFirst() + .orElse(null); + return matchEnum; + } + + /** + * 根据文本标签获取值 + * + * @param value + * @param clazz + * @param + * @return + */ + static & IBaseEnum> String getLabelByValue(Object value, Class clazz) { + Objects.requireNonNull(value); + EnumSet allEnums = EnumSet.allOf(clazz); // 获取类型下的所有枚举 + E matchEnum = allEnums.stream() + .filter(e -> ObjectUtil.equal(e.getValue(), value)) + .findFirst() + .orElse(null); + + String label = null; + if (matchEnum != null) { + label = matchEnum.getLabel(); + } + return label; + } + + + /** + * 根据文本标签获取值 + * + * @param label + * @param clazz + * @param + * @return + */ + static & IBaseEnum> Object getValueByLabel(String label, Class clazz) { + Objects.requireNonNull(label); + EnumSet allEnums = EnumSet.allOf(clazz); // 获取类型下的所有枚举 + String finalLabel = label; + E matchEnum = allEnums.stream() + .filter(e -> ObjectUtil.equal(e.getLabel(), finalLabel)) + .findFirst() + .orElse(null); + + Object value = null; + if (matchEnum != null) { + value = matchEnum.getValue(); + } + return value; + } + + +} diff --git a/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java b/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java new file mode 100644 index 0000000..9aaadcd --- /dev/null +++ b/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java @@ -0,0 +1,48 @@ +package com.youlai.boot.common.constant; + +/** + * JWT Claims声明常量 + *

+ * JWT Claims 属于 Payload 的一部分,包含了一些实体(通常指的用户)的状态和额外的元数据。 + * + * @author haoxr + * @since 2023/11/24 + */ +public interface JwtClaimConstants { + + /** + * 令牌类型 + */ + String TOKEN_TYPE = "tokenType"; + + /** + * 用户ID + */ + String USER_ID = "userId"; + + /** + * 部门ID + */ + String DEPT_ID = "deptId"; + + /** + * 数据权限列表 + *

+ * 存储用户所有角色的数据权限范围,用于实现多角色权限合并(并集策略) + */ + String DATA_SCOPES = "dataScopes"; + + /** + * 权限(角色Code)集合 + */ + String AUTHORITIES = "authorities"; + + /** + * Token 版本号 + *

+ * 用于用户级会话失效,当用户修改密码、被禁用、强制下线时递增版本号, + * 使该用户之前签发的所有 Token 失效。 + */ + String TOKEN_VERSION = "tokenVersion"; + +} diff --git a/src/main/java/com/youlai/boot/common/constant/RedisConstants.java b/src/main/java/com/youlai/boot/common/constant/RedisConstants.java new file mode 100644 index 0000000..b54c045 --- /dev/null +++ b/src/main/java/com/youlai/boot/common/constant/RedisConstants.java @@ -0,0 +1,63 @@ +package com.youlai.boot.common.constant; + +/** + * Redis 常量 + * + * @author Theo + * @since 2024-7-29 11:46:08 + */ +public interface RedisConstants { + + /** + * 限流相关键 + */ + interface RateLimiter { + String IP = "rate_limiter:ip:{}"; // IP限流(示例:rate_limiter:ip:192.168.1.1) + } + + /** + * 分布式锁相关键 + */ + interface Lock { + String RESUBMIT = "lock:resubmit:{}:{}"; // 防重复提交(示例:lock:resubmit:userIdentifier:requestIdentifier) + } + + /** + * 认证模块 + */ + interface Auth { + // 存储访问令牌对应的用户会话信息(accessToken -> UserSession) + String ACCESS_TOKEN_USER = "auth:token:access:{}"; + // 存储刷新令牌对应的用户会话信息(refreshToken -> UserSession) + String REFRESH_TOKEN_USER = "auth:token:refresh:{}"; + // 用户与访问令牌的映射(userId -> accessToken) + String USER_ACCESS_TOKEN = "auth:user:access:{}"; + // 用户与刷新令牌的映射(userId -> refreshToken + String USER_REFRESH_TOKEN = "auth:user:refresh:{}"; + // 已撤销 Token 的 JTI(单端退出/会话注销):如果 jti 在撤销列表中,则 Token 立即无效 + String BLACKLIST_TOKEN = "auth:token:blacklist:{}"; + String REVOKED_JTI = BLACKLIST_TOKEN; + // 用户 Token 版本号(用于按用户失效历史 JWT):token.tokenVersion != redis.tokenVersion => token 无效 + String USER_TOKEN_VERSION = "auth:user:token_version:{}"; + } + + /** + * 验证码模块 + */ + interface Captcha { + String IMAGE_CODE = "captcha:image:{}"; // 图形验证码 + String SMS_LOGIN_CODE = "captcha:sms_login:{}"; // 登录短信验证码 + String SMS_REGISTER_CODE = "captcha:sms_register:{}";// 注册短信验证码 + String MOBILE_CODE = "captcha:mobile:{}"; // 绑定、更换手机验证码 + String EMAIL_CODE = "captcha:email:{}"; // 邮箱验证码 + } + + /** + * 系统模块 + */ + interface System { + String CONFIG = "system:config"; // 系统配置 + String ROLE_PERMS = "system:role:perms"; // 系统角色和权限映射 + } + +} diff --git a/src/main/java/com/youlai/boot/common/constant/SecurityConstants.java b/src/main/java/com/youlai/boot/common/constant/SecurityConstants.java new file mode 100644 index 0000000..3301a29 --- /dev/null +++ b/src/main/java/com/youlai/boot/common/constant/SecurityConstants.java @@ -0,0 +1,25 @@ +package com.youlai.boot.common.constant; + +/** + * 安全模块常量 + * + * @author Ray.Hao + * @since 2023/11/24 + */ +public interface SecurityConstants { + + /** + * 登录路径 + */ + String LOGIN_PATH = "/api/v1/auth/login"; + + /** + * JWT Token 前缀 + */ + String BEARER_TOKEN_PREFIX = "Bearer "; + + /** + * 角色前缀,用于区分 authorities 角色和权限, ROLE_* 角色 、没有前缀的是权限 + */ + String ROLE_PREFIX = "ROLE_"; +} diff --git a/src/main/java/com/youlai/boot/common/constant/SystemConstants.java b/src/main/java/com/youlai/boot/common/constant/SystemConstants.java new file mode 100644 index 0000000..4348974 --- /dev/null +++ b/src/main/java/com/youlai/boot/common/constant/SystemConstants.java @@ -0,0 +1,32 @@ +package com.youlai.boot.common.constant; + +/** + * 系统常量 + * + * @author Ray.Hao + * @since 1.0.0 + */ +public interface SystemConstants { + + /** + * 根节点ID + */ + Long ROOT_NODE_ID = 0L; + + /** + * 系统默认密码 + */ + String DEFAULT_PASSWORD = "123456"; + + /** + * 超级管理员角色编码 + */ + String ROOT_ROLE_CODE = "ROOT"; + + + /** + * 系统配置 IP的QPS限流的KEY + */ + String SYSTEM_CONFIG_IP_QPS_LIMIT_KEY = "IP_QPS_THRESHOLD_LIMIT"; + +} diff --git a/src/main/java/com/youlai/boot/common/enums/ActionTypeEnum.java b/src/main/java/com/youlai/boot/common/enums/ActionTypeEnum.java new file mode 100644 index 0000000..3b71821 --- /dev/null +++ b/src/main/java/com/youlai/boot/common/enums/ActionTypeEnum.java @@ -0,0 +1,56 @@ +package com.youlai.boot.common.enums; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.fasterxml.jackson.annotation.JsonValue; +import com.youlai.boot.common.base.IBaseEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +/** + * 操作类型枚举 + * + * @author Ray + * @since 2.10.0 + */ +@Schema(enumAsRef = true) +@Getter +public enum ActionTypeEnum implements IBaseEnum { + + LOGIN(1, "登录"), + LOGOUT(2, "登出"), + INSERT(3, "新增"), + UPDATE(4, "修改"), + DELETE(5, "删除"), + GRANT(6, "授权"), + EXPORT(7, "导出"), + IMPORT(8, "导入"), + UPLOAD(9, "上传"), + DOWNLOAD(10, "下载"), + CHANGE_PASSWORD(11, "修改密码"), + RESET_PASSWORD(12, "重置密码"), + ENABLE(13, "启用"), + DISABLE(14, "禁用"), + LIST(15, "查询列表"), + OTHER(99, "其他"); + + @EnumValue + private final Integer value; + + @JsonValue + private final String label; + + ActionTypeEnum(Integer value, String label) { + this.value = value; + this.label = label; + } + + @Override + public Integer getValue() { + return this.value; + } + + @Override + public String getLabel() { + return this.label; + } +} diff --git a/src/main/java/com/youlai/boot/common/enums/CaptchaTypeEnum.java b/src/main/java/com/youlai/boot/common/enums/CaptchaTypeEnum.java new file mode 100644 index 0000000..8d3fe10 --- /dev/null +++ b/src/main/java/com/youlai/boot/common/enums/CaptchaTypeEnum.java @@ -0,0 +1,27 @@ +package com.youlai.boot.common.enums; + +/** + * EasyCaptcha 验证码类型枚举 + * + * @author haoxr + * @since 2.5.1 + */ +public enum CaptchaTypeEnum { + + /** + * 圆圈干扰验证码 + */ + CIRCLE, + /** + * GIF验证码 + */ + GIF, + /** + * 干扰线验证码 + */ + LINE, + /** + * 扭曲干扰验证码 + */ + SHEAR +} diff --git a/src/main/java/com/youlai/boot/common/enums/DataScopeEnum.java b/src/main/java/com/youlai/boot/common/enums/DataScopeEnum.java new file mode 100644 index 0000000..37a2636 --- /dev/null +++ b/src/main/java/com/youlai/boot/common/enums/DataScopeEnum.java @@ -0,0 +1,81 @@ +package com.youlai.boot.common.enums; + +import com.youlai.boot.common.base.IBaseEnum; +import lombok.Getter; + +/** + * 数据权限枚举 + *

+ * 多角色数据权限合并策略:取并集(OR),即用户能看到所有角色权限范围内的数据。 + * 如果任一角色是 ALL,则直接跳过数据权限过滤。 + * + * @author Ray.Hao + * @since 2.3.0 + */ +@Getter +public enum DataScopeEnum implements IBaseEnum { + + /** + * 所有数据权限 - 最高权限,可查看所有数据 + */ + ALL(1, "所有数据"), + + /** + * 部门及子部门数据 - 可查看本部门及其下属所有部门的数据 + */ + DEPT_AND_SUB(2, "部门及子部门数据"), + + /** + * 本部门数据 - 仅可查看本部门的数据 + */ + DEPT(3, "本部门数据"), + + /** + * 本人数据 - 仅可查看自己的数据 + */ + SELF(4, "本人数据"), + + /** + * 自定义部门数据 - 可查看指定部门的数据 + *

+ * 需要配合 sys_role_dept 表使用,存储角色可访问的部门ID列表 + */ + CUSTOM(5, "自定义部门数据"); + + private final Integer value; + + private final String label; + + DataScopeEnum(Integer value, String label) { + this.value = value; + this.label = label; + } + + /** + * 判断是否为全部数据权限 + * + * @param value 数据权限值 + * @return 是否为全部数据权限 + */ + public static boolean isAll(Integer value) { + return ALL.getValue().equals(value); + } + + /** + * 根据值获取枚举 + * + * @param value 数据权限值 + * @return 枚举对象,未找到则返回 null + */ + public static DataScopeEnum getByValue(Integer value) { + if (value == null) { + return null; + } + for (DataScopeEnum dataScope : values()) { + if (dataScope.getValue().equals(value)) { + return dataScope; + } + } + return null; + } +} diff --git a/src/main/java/com/youlai/boot/common/enums/EnvEnum.java b/src/main/java/com/youlai/boot/common/enums/EnvEnum.java new file mode 100644 index 0000000..9f502fa --- /dev/null +++ b/src/main/java/com/youlai/boot/common/enums/EnvEnum.java @@ -0,0 +1,26 @@ +package com.youlai.boot.common.enums; + +import com.youlai.boot.common.base.IBaseEnum; +import lombok.Getter; + +/** + * 环境枚举 + * + * @author Ray + * @since 4.0.0 + */ +@Getter +public enum EnvEnum implements IBaseEnum { + + DEV("dev", "开发环境"), + PROD("prod", "生产环境"); + + private final String value; + + private final String label; + + EnvEnum(String value, String label) { + this.value = value; + this.label = label; + } +} diff --git a/src/main/java/com/youlai/boot/common/enums/LogModuleEnum.java b/src/main/java/com/youlai/boot/common/enums/LogModuleEnum.java new file mode 100644 index 0000000..dce1ea3 --- /dev/null +++ b/src/main/java/com/youlai/boot/common/enums/LogModuleEnum.java @@ -0,0 +1,52 @@ +package com.youlai.boot.common.enums; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.fasterxml.jackson.annotation.JsonValue; +import com.youlai.boot.common.base.IBaseEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +/** + * 日志模块枚举 + * + * @author Ray + * @since 2.10.0 + */ +@Schema(enumAsRef = true) +@Getter +public enum LogModuleEnum implements IBaseEnum { + + LOGIN(1, "登录"), + USER(2, "用户管理"), + ROLE(3, "角色管理"), + DEPT(4, "部门管理"), + MENU(5, "菜单管理"), + DICT(6, "字典管理"), + CONFIG(7, "系统配置"), + FILE(8, "文件管理"), + NOTICE(9, "通知公告"), + LOG(10, "日志管理"), + CODEGEN(11, "代码生成"), + OTHER(99, "其他"); + + @EnumValue + private final Integer value; + + @JsonValue + private final String label; + + LogModuleEnum(Integer value, String label) { + this.value = value; + this.label = label; + } + + @Override + public Integer getValue() { + return this.value; + } + + @Override + public String getLabel() { + return this.label; + } +} diff --git a/src/main/java/com/youlai/boot/common/enums/StatusEnum.java b/src/main/java/com/youlai/boot/common/enums/StatusEnum.java new file mode 100644 index 0000000..229086f --- /dev/null +++ b/src/main/java/com/youlai/boot/common/enums/StatusEnum.java @@ -0,0 +1,27 @@ +package com.youlai.boot.common.enums; + +import com.youlai.boot.common.base.IBaseEnum; +import lombok.Getter; + +/** + * 状态枚举 + * + * @author haoxr + * @since 2022/10/14 + */ +@Getter +public enum StatusEnum implements IBaseEnum { + + ENABLE(1, "启用"), + DISABLE (0, "禁用"); + + private final Integer value; + + + private final String label; + + StatusEnum(Integer value, String label) { + this.value = value; + this.label = label; + } +} diff --git a/src/main/java/com/youlai/boot/common/exception/BusinessException.java b/src/main/java/com/youlai/boot/common/exception/BusinessException.java new file mode 100644 index 0000000..0646709 --- /dev/null +++ b/src/main/java/com/youlai/boot/common/exception/BusinessException.java @@ -0,0 +1,45 @@ +package com.youlai.boot.common.exception; + +import com.youlai.boot.common.result.IResultCode; +import lombok.Getter; +import org.slf4j.helpers.MessageFormatter; + +/** + * 自定义业务异常 + * + * @author Ray + * @since 2022/7/31 + */ +@Getter +public class BusinessException extends RuntimeException { + + public IResultCode resultCode; + + public BusinessException(IResultCode errorCode) { + super(errorCode.getMsg()); + this.resultCode = errorCode; + } + + + public BusinessException(IResultCode errorCode,String message) { + super(message); + this.resultCode = errorCode; + } + + + public BusinessException(String message, Throwable cause) { + super(message, cause); + } + + public BusinessException(Throwable cause) { + super(cause); + } + + public BusinessException(String message, Object... args) { + super(formatMessage(message, args)); + } + + private static String formatMessage(String message, Object... args) { + return MessageFormatter.arrayFormat(message, args).getMessage(); + } +} diff --git a/src/main/java/com/youlai/boot/common/model/KeyValue.java b/src/main/java/com/youlai/boot/common/model/KeyValue.java new file mode 100644 index 0000000..8cc38ff --- /dev/null +++ b/src/main/java/com/youlai/boot/common/model/KeyValue.java @@ -0,0 +1,30 @@ +package com.youlai.boot.common.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + + +/** + * 键值对 + * + * @author haoxr + * @since 2024/5/25 + */ +@Schema(description = "键值对") +@Data +@NoArgsConstructor +public class KeyValue { + + public KeyValue(String key, String value) { + this.key = key; + this.value = value; + } + + @Schema(description = "选项的值") + private String key; + + @Schema(description = "选项的标签") + private String value; + +} diff --git a/src/main/java/com/youlai/boot/common/model/Option.java b/src/main/java/com/youlai/boot/common/model/Option.java new file mode 100644 index 0000000..78360f2 --- /dev/null +++ b/src/main/java/com/youlai/boot/common/model/Option.java @@ -0,0 +1,53 @@ +package com.youlai.boot.common.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 下拉选项对象 + * + * @author haoxr + * @since 2022/1/22 + */ +@Schema(description ="下拉选项对象") +@Data +@NoArgsConstructor +public class Option { + + public Option(T value, String label) { + this.value = value; + this.label = label; + } + + public Option(T value, String label, List> children) { + this.value = value; + this.label = label; + this.children= children; + } + + public Option(T value, String label, String tag) { + this.value = value; + this.label = label; + this.tag= tag; + } + + + @Schema(description="选项的值") + private T value; + + @Schema(description="选项的标签") + private String label; + + @Schema(description = "标签类型") + @JsonInclude(value = JsonInclude.Include.NON_EMPTY) + private String tag; + + @Schema(description="子选项列表") + @JsonInclude(value = JsonInclude.Include.NON_EMPTY) + private List> children; + +} diff --git a/src/main/java/com/youlai/boot/common/result/ExcelResult.java b/src/main/java/com/youlai/boot/common/result/ExcelResult.java new file mode 100644 index 0000000..d9d6590 --- /dev/null +++ b/src/main/java/com/youlai/boot/common/result/ExcelResult.java @@ -0,0 +1,43 @@ +package com.youlai.boot.common.result; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * Excel导出响应结构体 + * + * @author Theo + * @since 2025/1/14 11:46:08 + */ +@Data +public class ExcelResult { + + /** + * 响应码,来确定是否导入成功 + */ + private String code; + + /** + * 有效条数 + */ + private Integer validCount; + + /** + * 无效条数 + */ + private Integer invalidCount; + + /** + * 错误提示信息 + */ + private List messageList; + + public ExcelResult() { + this.code = ResultCode.SUCCESS.getCode(); + this.validCount = 0; + this.invalidCount = 0; + this.messageList = new ArrayList<>(); + } +} diff --git a/src/main/java/com/youlai/boot/common/result/IResultCode.java b/src/main/java/com/youlai/boot/common/result/IResultCode.java new file mode 100644 index 0000000..741d604 --- /dev/null +++ b/src/main/java/com/youlai/boot/common/result/IResultCode.java @@ -0,0 +1,15 @@ +package com.youlai.boot.common.result; + +/** + * 响应码接口 + * + * @author Ray.Hao + * @since 1.0.0 + **/ +public interface IResultCode { + + String getCode(); + + String getMsg(); + +} diff --git a/src/main/java/com/youlai/boot/common/result/PageResult.java b/src/main/java/com/youlai/boot/common/result/PageResult.java new file mode 100644 index 0000000..72fee0b --- /dev/null +++ b/src/main/java/com/youlai/boot/common/result/PageResult.java @@ -0,0 +1,71 @@ +package com.youlai.boot.common.result; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import lombok.Data; + +import java.io.Serializable; +import java.util.Collections; +import java.util.List; + +/** + * 分页响应结构体 + * + * @author Ray.Hao + * @since 2022/2/18 + */ +@Data +public class PageResult implements Serializable { + + private String code; + + private String msg; + + private PageData data; + + /** + * 构建分页结果(MyBatis-Plus {@link IPage})。 + * + *

data 为当前页记录列表;page 提供分页元信息。

+ */ + public static PageResult success(IPage page) { + PageResult result = new PageResult<>(); + result.setCode(ResultCode.SUCCESS.getCode()); + result.setMsg(ResultCode.SUCCESS.getMsg()); + + List records = + (page == null || page.getRecords() == null) + ? Collections.emptyList() + : page.getRecords(); + PageData pageData = new PageData<>(); + pageData.setList(records); + pageData.setTotal(page != null ? page.getTotal() : 0L); + result.setData(pageData); + + return result; + } + + /** + * 构建列表结果(无分页)。 + * + *

page 置为 null,用于与分页返回区分。

+ */ + public static PageResult success(List list) { + PageResult result = new PageResult<>(); + result.setCode(ResultCode.SUCCESS.getCode()); + result.setMsg(ResultCode.SUCCESS.getMsg()); + PageData pageData = new PageData<>(); + pageData.setList(list != null ? list : Collections.emptyList()); + pageData.setTotal(0L); + result.setData(pageData); + return result; + } + + @Data + public static class PageData { + + private List list; + + private long total; + } + +} diff --git a/src/main/java/com/youlai/boot/common/result/ResponseWriter.java b/src/main/java/com/youlai/boot/common/result/ResponseWriter.java new file mode 100644 index 0000000..9b03399 --- /dev/null +++ b/src/main/java/com/youlai/boot/common/result/ResponseWriter.java @@ -0,0 +1,122 @@ +package com.youlai.boot.common.result; + +import cn.hutool.extra.servlet.JakartaServletUtil; +import cn.hutool.json.JSONUtil; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; + +import java.nio.charset.StandardCharsets; + +/** + * 响应写入器 + *

+ * 用于在过滤器、Security处理器等无法使用 @RestControllerAdvice 的场景中统一写入HTTP响应。 + * 支持写入成功响应和错误响应。 + * 此类为工具类,所有方法均为静态方法,禁止实例化。 + * + * @author Ray.Hao + * @since 2.0.0 + */ +@Slf4j +public final class ResponseWriter { + + /** + * 私有构造函数,防止实例化 + */ + private ResponseWriter() { + throw new UnsupportedOperationException("工具类不允许实例化"); + } + + /** + * 写入成功响应 + * + * @param response HttpServletResponse + * @param data 响应数据(可选) + */ + public static void writeSuccess(HttpServletResponse response, Object data) { + writeResult(response, Result.success(data), HttpStatus.OK.value()); + } + + /** + * 写入成功响应(无数据) + * + * @param response HttpServletResponse + */ + public static void writeSuccess(HttpServletResponse response) { + writeSuccess(response, null); + } + + /** + * 写入错误响应 + * + * @param response HttpServletResponse + * @param resultCode 响应结果码 + */ + public static void writeError(HttpServletResponse response, ResultCode resultCode) { + writeError(response, resultCode, null); + } + + /** + * 写入错误响应(带自定义消息) + * + * @param response HttpServletResponse + * @param resultCode 响应结果码 + * @param message 自定义消息(可选,为 null 时使用 resultCode 的默认消息) + */ + public static void writeError(HttpServletResponse response, ResultCode resultCode, String message) { + Result result = message == null + ? Result.failed(resultCode) + : Result.failed(resultCode, message); + + int httpStatus = mapHttpStatus(resultCode); + writeResult(response, result, httpStatus); + } + + /** + * 写入响应结果(通用方法) + * + * @param response HttpServletResponse + * @param result 响应结果对象 + * @param httpStatus HTTP状态码 + */ + private static void writeResult(HttpServletResponse response, Result result, int httpStatus) { + try { + // 设置HTTP状态码 + response.setStatus(httpStatus); + + // 设置响应编码和内容类型 + response.setCharacterEncoding(StandardCharsets.UTF_8.toString()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + // 写入响应 + JakartaServletUtil.write(response, + JSONUtil.toJsonStr(result), + MediaType.APPLICATION_JSON_VALUE + ); + + } catch (Exception e) { + log.error("写入响应时发生未知异常: httpStatus={}, result={}", httpStatus, result, e); + } + } + + /** + * 根据业务结果码映射HTTP状态码 + * 401: 未认证(token无效/过期) + * 403: 权限不足 + * 400: 其他业务错误 + * + * @param resultCode 业务结果码 + * @return HTTP状态码 + */ + private static int mapHttpStatus(ResultCode resultCode) { + return switch (resultCode) { + case ACCESS_UNAUTHORIZED, + ACCESS_TOKEN_INVALID, + REFRESH_TOKEN_INVALID -> HttpStatus.UNAUTHORIZED.value(); + case ACCESS_PERMISSION_EXCEPTION -> HttpStatus.FORBIDDEN.value(); + default -> HttpStatus.BAD_REQUEST.value(); + }; + } +} diff --git a/src/main/java/com/youlai/boot/common/result/Result.java b/src/main/java/com/youlai/boot/common/result/Result.java new file mode 100644 index 0000000..9415379 --- /dev/null +++ b/src/main/java/com/youlai/boot/common/result/Result.java @@ -0,0 +1,79 @@ +package com.youlai.boot.common.result; + +import cn.hutool.core.util.StrUtil; +import lombok.Data; + +import java.io.Serializable; + +/** + * 统一响应结构体 + * + * @author Ray.Hao + * @since 2022/1/30 + **/ +@Data +public class Result implements Serializable { + + private String code; + + private T data; + + private String msg; + + public static Result success() { + return success(null); + } + + public static Result success(T data) { + Result result = new Result<>(); + result.setCode(ResultCode.SUCCESS.getCode()); + result.setMsg(ResultCode.SUCCESS.getMsg()); + result.setData(data); + return result; + } + + public static Result failed() { + return result(ResultCode.SYSTEM_ERROR.getCode(), ResultCode.SYSTEM_ERROR.getMsg(), null); + } + + public static Result failed(String msg) { + return result(ResultCode.SYSTEM_ERROR.getCode(), msg, null); + } + + public static Result judge(boolean status) { + if (status) { + return success(); + } else { + return failed(); + } + } + + public static Result failed(IResultCode resultCode) { + return result(resultCode.getCode(), resultCode.getMsg(), null); + } + + public static Result failed(IResultCode resultCode, String msg) { + return result(resultCode.getCode(), StrUtil.isNotBlank(msg) ? msg : resultCode.getMsg(), null); + } + + public static Result failed(IResultCode resultCode, T data) { + return result(resultCode.getCode(), resultCode.getMsg(), data); + } + + public static Result failed(IResultCode resultCode, String msg, T data) { + return result(resultCode.getCode(), StrUtil.isNotBlank(msg) ? msg : resultCode.getMsg(), data); + } + + private static Result result(IResultCode resultCode, T data) { + return result(resultCode.getCode(), resultCode.getMsg(), data); + } + + private static Result result(String code, String msg, T data) { + Result result = new Result<>(); + result.setCode(code); + result.setData(data); + result.setMsg(msg); + return result; + } + +} diff --git a/src/main/java/com/youlai/boot/common/result/ResultCode.java b/src/main/java/com/youlai/boot/common/result/ResultCode.java new file mode 100644 index 0000000..a0833d1 --- /dev/null +++ b/src/main/java/com/youlai/boot/common/result/ResultCode.java @@ -0,0 +1,155 @@ +package com.youlai.boot.common.result; + +import java.io.Serializable; + +/** + * 响应码枚举 + *

+ * 参考《阿里巴巴 Java 开发手册》错误码设计建议: + * 00000 表示成功。 + * A**** 表示用户端错误(如参数错误、认证失败等)。 + * B**** 表示当前系统执行出错(如系统超时等)。 + * C**** 表示调用第三方服务出错(如中间件、数据库等外部依赖)。 + *

+ * 错误码位数与号段说明: + * - 错误码为字符串类型,共 5 位:错误产生来源(A/B/C) + 四位数字编号。 + * - 四位数字编号范围 0001~9999,大类之间建议按步长 100 预留号段(如 A0200、A0300、A0400)。 + * - 错误码后三位编号与 HTTP 状态码无关。 + *

+ * 说明: + * - 本项目仅保留实际使用的错误码,并在 A/B/C 各保留少量示例,避免枚举无限膨胀。 + * - 如需扩展业务错误码,建议在对应宏观分类下按场景划分号段并保持全局唯一。 + *

+ * 附表(节选):错误码列表(示例/项目使用项) + *

+ * | 错误码 | 中文描述             | 说明             |
+ * |-------|----------------------|------------------|
+ * | 00000 | 成功                 | 正常执行后的返回 |
+ * | A0001 | 用户端错误           | 一级宏观错误码   |
+ * | A0100 | 用户注册错误         | 二级宏观错误码   |
+ * | A0101 | 用户未同意隐私协议   | 二级宏观错误码   |
+ * | A0200 | 用户登录异常         | 二级宏观错误码   |
+ * | A0201 | 用户账户不存在       | 二级宏观错误码   |
+ * | A0202 | 用户账户被冻结       | 二级宏观错误码   |
+ * | A0230 | 访问令牌无效或已过期 | 令牌校验失败     |
+ * | A0241 | 用户验证码尝试次数超限 | 二级宏观错误码   |
+ * | A0300 | 访问权限异常         | 二级宏观错误码   |
+ * | A0301 | 访问未授权           | 二级宏观错误码   |
+ * | A0400 | 用户请求参数错误     | 二级宏观错误码   |
+ * | A0410 | 请求必填参数为空     | 二级宏观错误码   |
+ * | A0500 | 用户请求服务异常     | 二级宏观错误码   |
+ * | A0502 | 请求并发数超出限制   | 二级宏观错误码   |
+ * | A0506 | 请勿重复提交         | 二级宏观错误码   |
+ * | B0001 | 系统执行出错         | 一级宏观错误码   |
+ * | B0100 | 系统执行超时         | 二级宏观错误码   |
+ * | C0001 | 调用第三方服务出错   | 一级宏观错误码   |
+ * | C0113 | 接口不存在           | 二级宏观错误码   |
+ * | C0300 | 数据库服务出错       | 二级宏观错误码   |
+ * 
+ * + * @author Ray.Hao + * @since 2020/6/23 + **/ +public enum ResultCode implements IResultCode, Serializable { + + SUCCESS("00000", "成功"), + + /** 一级宏观错误码:用户端错误(由客户端输入/认证/权限/请求方式等引起,需客户端配合修正) */ + USER_ERROR("A0001", "用户端错误"), + + + /** 二级宏观错误码:用户端具体错误(按号段细分,便于定位是注册/登录/令牌/参数/防重等问题) */ + + /** A01xx:用户注册错误 */ + USER_REGISTRATION_ERROR("A0100", "用户注册错误"), + USER_NOT_AGREE_PRIVACY_AGREEMENT("A0101", "用户未同意隐私协议"), + + /** A013x:校验码输入错误 */ + VERIFICATION_CODE_INPUT_ERROR("A0130", "校验码输入错误"), + + /** A02xx:用户登录异常 */ + USER_LOGIN_EXCEPTION("A0200", "用户登录异常"), + ACCOUNT_NOT_FOUND("A0201", "用户账户不存在"), + ACCOUNT_FROZEN("A0202", "用户账户被冻结"), + USER_PASSWORD_ERROR("A0210", "用户名或密码错误"), + /** A023x:令牌无效或已过期 */ + ACCESS_TOKEN_INVALID("A0230", "访问令牌无效或已过期"), + REFRESH_TOKEN_INVALID("A0231", "刷新令牌无效或已过期"), + /** A024x:验证码错误 */ + USER_VERIFICATION_CODE_ERROR("A0240", "验证码错误"), + USER_VERIFICATION_CODE_ATTEMPT_LIMIT_EXCEEDED("A0241", "用户验证码尝试次数超限"), + USER_VERIFICATION_CODE_EXPIRED("A0242", "用户验证码过期"), + + /** A03xx:访问权限异常 */ + ACCESS_PERMISSION_EXCEPTION("A0300", "访问权限异常"), + ACCESS_UNAUTHORIZED("A0301", "访问未授权"), + + /** A04xx:用户请求参数错误 */ + USER_REQUEST_PARAMETER_ERROR("A0400", "用户请求参数错误"), + INVALID_USER_INPUT("A0402", "无效的用户输入"), + REQUEST_REQUIRED_PARAMETER_IS_EMPTY("A0410", "请求必填参数为空"), + PARAMETER_FORMAT_MISMATCH("A0421", "参数格式不匹配"), + + /** A05xx:用户请求服务异常 */ + USER_REQUEST_SERVICE_EXCEPTION("A0500", "用户请求服务异常"), + REQUEST_CONCURRENCY_LIMIT_EXCEEDED("A0502", "请求并发数超出限制"), + DUPLICATE_SUBMISSION("A0506", "请勿重复提交"), + + /** A07xx:文件处理异常 */ + UPLOAD_FILE_EXCEPTION("A0700", "上传文件异常"), + DELETE_FILE_EXCEPTION("A0710", "删除文件异常"), + + /** 一级宏观错误码:系统端错误(服务端内部异常/超时/不可用等,需后端排查修复) */ + SYSTEM_ERROR("B0001", "系统执行出错"), + + /** 二级宏观错误码:系统端具体错误(按号段细分,便于定位超时/限流/资源耗尽等) */ + SYSTEM_EXECUTION_TIMEOUT("B0100", "系统执行超时"), + + /** 一级宏观错误码:第三方服务错误(外部依赖/中间件/数据库等引起,需检查依赖健康与配置) */ + THIRD_PARTY_SERVICE_ERROR("C0001", "调用第三方服务出错"), + + /** 二级宏观错误码:第三方服务具体错误(按号段细分,便于定位是接口不存在/数据库异常等) */ + INTERFACE_NOT_EXIST("C0113", "接口不存在"), + DATABASE_SERVICE_ERROR("C0300", "数据库服务出错"), + DATABASE_EXECUTION_SYNTAX_ERROR("C0313", "数据库执行语法错误"), + INTEGRITY_CONSTRAINT_VIOLATION("C0342", "违反了完整性约束"), + DATABASE_ACCESS_DENIED("C0351", "演示环境已禁用数据库写入功能,请本地部署修改数据库链接或开启Mock模式进行体验"); + + private final String code; + + private final String msg; + + ResultCode(String code, String msg) { + this.code = code; + this.msg = msg; + } + + + @Override + public String getCode() { + return code; + } + + @Override + public String getMsg() { + return msg; + } + + @Override + public String toString() { + return "{" + + "\"code\":\"" + code + '\"' + + ", \"msg\":\"" + msg + '\"' + + '}'; + } + + + public static ResultCode getValue(String code) { + for (ResultCode value : values()) { + if (value.getCode().equals(code)) { + return value; + } + } + return SYSTEM_ERROR; // 默认系统执行错误 + } +} diff --git a/src/main/java/com/youlai/boot/common/util/ExcelUtils.java b/src/main/java/com/youlai/boot/common/util/ExcelUtils.java new file mode 100644 index 0000000..e2d419b --- /dev/null +++ b/src/main/java/com/youlai/boot/common/util/ExcelUtils.java @@ -0,0 +1,19 @@ +package com.youlai.boot.common.util; + +import cn.idev.excel.EasyExcel; +import cn.idev.excel.event.AnalysisEventListener; + +import java.io.InputStream; + +/** + * Excel 工具类 + * + * @author haoxr + * @since 2023/03/01 + */ +public class ExcelUtils { + + public static void importExcel(InputStream is, Class clazz, AnalysisEventListener listener) { + EasyExcel.read(is, clazz, listener).sheet().doRead(); + } +} diff --git a/src/main/java/com/youlai/boot/common/util/IPUtils.java b/src/main/java/com/youlai/boot/common/util/IPUtils.java new file mode 100644 index 0000000..ae1480c --- /dev/null +++ b/src/main/java/com/youlai/boot/common/util/IPUtils.java @@ -0,0 +1,139 @@ +package com.youlai.boot.common.util; + +import cn.hutool.core.util.StrUtil; +import jakarta.annotation.PostConstruct; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.lionsoul.ip2region.xdb.Searcher; +import org.springframework.stereotype.Component; + +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; + +/** + * IP工具类 + *

+ * 获取客户端IP地址和IP地址对应的地理位置信息 + *

+ * 使用Nginx等反向代理软件, 则不能通过request.getRemoteAddr()获取IP地址 + * 如果使用了多级反向代理的话,X-Forwarded-For的值并不止一个,而是一串IP地址,X-Forwarded-For中第一个非unknown的有效IP字符串,则为真实IP地址 + *

+ * + * @author Ray + * @since 2.10.0 + */ +@Slf4j +@Component +public class IPUtils { + + private static final String DB_PATH = "/data/ip2region.xdb"; + private static Searcher searcher; + + @PostConstruct + public void init() { + try { + // 从类路径加载资源文件 + InputStream inputStream = getClass().getResourceAsStream(DB_PATH); + if (inputStream == null) { + throw new FileNotFoundException("Resource not found: " + DB_PATH); + } + + // 将资源文件复制到临时文件 + Path tempDbPath = Files.createTempFile("ip2region", ".xdb"); + Files.copy(inputStream, tempDbPath, StandardCopyOption.REPLACE_EXISTING); + + // 使用临时文件初始化 Searcher 对象 + searcher = Searcher.newWithFileOnly(tempDbPath.toString()); + } catch (Exception e) { + log.error("IpRegionUtil initialization ERROR, {}", e.getMessage()); + } + } + + /** + * 获取IP地址 + * + * @param request HttpServletRequest对象 + * @return 客户端IP地址 + */ + public static String getIpAddr(HttpServletRequest request) { + String ip = null; + try { + if (request == null) { + return ""; + } + ip = request.getHeader("x-forwarded-for"); + if (checkIp(ip)) { + ip = request.getHeader("Proxy-Client-IP"); + } + if (checkIp(ip)) { + ip = request.getHeader("WL-Proxy-Client-IP"); + } + if (checkIp(ip)) { + ip = request.getHeader("HTTP_CLIENT_IP"); + } + if (checkIp(ip)) { + ip = request.getHeader("HTTP_X_FORWARDED_FOR"); + } + if (checkIp(ip)) { + ip = request.getRemoteAddr(); + if ("127.0.0.1".equals(ip) || "0:0:0:0:0:0:0:1".equals(ip)) { + // 根据网卡取本机配置的IP + ip = getLocalAddr(); + } + } + } catch (Exception e) { + log.error("IPUtils ERROR, {}", e.getMessage()); + } + + // 使用代理,则获取第一个IP地址 + if (StrUtil.isNotBlank(ip) && ip.indexOf(",") > 0) { + ip = ip.substring(0, ip.indexOf(",")); + } + + return ip; + } + + private static boolean checkIp(String ip) { + String unknown = "unknown"; + return StrUtil.isEmpty(ip) || unknown.equalsIgnoreCase(ip); + } + + /** + * 获取本机的IP地址 + * + * @return 本机IP地址 + */ + private static String getLocalAddr() { + try { + return InetAddress.getLocalHost().getHostAddress(); + } catch (UnknownHostException e) { + log.error("InetAddress.getLocalHost()-error, {}", e.getMessage()); + } + return null; + } + + /** + * 根据IP地址获取地理位置信息 + * + * @param ip IP地址 + * @return 地理位置信息 + */ + public static String getRegion(String ip) { + if (searcher == null) { + log.error("Searcher is not initialized"); + return null; + } + + try { + return searcher.search(ip); + } catch (Exception e) { + log.error("IpRegionUtil ERROR, {}", e.getMessage()); + return null; + } + } +} diff --git a/src/main/java/com/youlai/boot/common/validator/FieldValidator.java b/src/main/java/com/youlai/boot/common/validator/FieldValidator.java new file mode 100644 index 0000000..200e67c --- /dev/null +++ b/src/main/java/com/youlai/boot/common/validator/FieldValidator.java @@ -0,0 +1,31 @@ +package com.youlai.boot.common.validator; + +import com.youlai.boot.common.annotation.ValidField; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +import java.util.Arrays; + +/** + * 字段校验器 + * + * @author Ray.Hao + * @since 2024/11/18 + */ +public class FieldValidator implements ConstraintValidator { + + private String[] allowedValues; + + @Override + public void initialize(ValidField constraintAnnotation) { + this.allowedValues = constraintAnnotation.allowedValues(); + } + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null) { + return true; + } + return Arrays.asList(allowedValues).contains(value); + } +} diff --git a/src/main/java/com/youlai/boot/file/controller/FileController.java b/src/main/java/com/youlai/boot/file/controller/FileController.java new file mode 100644 index 0000000..0975f8c --- /dev/null +++ b/src/main/java/com/youlai/boot/file/controller/FileController.java @@ -0,0 +1,55 @@ +package com.youlai.boot.file.controller; + +import com.youlai.boot.common.result.Result; +import com.youlai.boot.file.service.FileService; +import com.youlai.boot.file.model.FileInfo; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +/** + * 文件控制层 + * + * @author Ray.Hao + * @since 2022/10/16 + */ +@Tag(name = "10.文件接口") +@RestController +@RequestMapping("/api/v1/files") +@RequiredArgsConstructor +public class FileController { + + private final FileService fileService; + + @PostMapping + @Operation(summary = "文件上传") + public Result uploadFile( + @Parameter( + name = "file", + description = "表单文件对象", + required = true, + in = ParameterIn.DEFAULT, + schema = @Schema(name = "file", format = "binary") + ) + @RequestPart(value = "file") MultipartFile file + ) { + FileInfo fileInfo = fileService.uploadFile(file); + return Result.success(fileInfo); + } + + @DeleteMapping + @Operation(summary = "文件删除") + @SneakyThrows + public Result deleteFile( + @Parameter(description = "文件路径") @RequestParam String filePath + ) { + boolean result = fileService.deleteFile(filePath); + return Result.judge(result); + } +} diff --git a/src/main/java/com/youlai/boot/file/model/FileInfo.java b/src/main/java/com/youlai/boot/file/model/FileInfo.java new file mode 100644 index 0000000..5be4f4c --- /dev/null +++ b/src/main/java/com/youlai/boot/file/model/FileInfo.java @@ -0,0 +1,23 @@ +package com.youlai.boot.file.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + + +/** + * 文件信息对象 + * + * @author Ray.Hao + * @since 1.0.0 + */ +@Schema(description = "文件对象") +@Data +public class FileInfo { + + @Schema(description = "文件名称") + private String name; + + @Schema(description = "文件URL") + private String url; + +} diff --git a/src/main/java/com/youlai/boot/file/service/FileService.java b/src/main/java/com/youlai/boot/file/service/FileService.java new file mode 100644 index 0000000..6e5d280 --- /dev/null +++ b/src/main/java/com/youlai/boot/file/service/FileService.java @@ -0,0 +1,30 @@ +package com.youlai.boot.file.service; + +import com.youlai.boot.file.model.FileInfo; +import org.springframework.web.multipart.MultipartFile; + +/** + * 对象存储服务接口层 + * + * @author haoxr + * @since 2022/11/19 + */ +public interface FileService { + + /** + * 上传文件 + * @param file 表单文件对象 + * @return 文件信息 + */ + FileInfo uploadFile(MultipartFile file); + + /** + * 删除文件 + * + * @param filePath 文件完整URL + * @return 删除结果 + */ + boolean deleteFile(String filePath); + + +} diff --git a/src/main/java/com/youlai/boot/file/service/impl/AliyunFileService.java b/src/main/java/com/youlai/boot/file/service/impl/AliyunFileService.java new file mode 100644 index 0000000..3d699ff --- /dev/null +++ b/src/main/java/com/youlai/boot/file/service/impl/AliyunFileService.java @@ -0,0 +1,100 @@ +package com.youlai.boot.file.service.impl; + +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.IdUtil; +import com.aliyun.oss.OSS; +import com.aliyun.oss.OSSClientBuilder; +import com.aliyun.oss.model.ObjectMetadata; +import com.aliyun.oss.model.PutObjectRequest; +import com.youlai.boot.file.service.FileService; +import com.youlai.boot.file.model.FileInfo; +import jakarta.annotation.PostConstruct; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; +import java.time.LocalDateTime; + +/** + * Aliyun 对象存储服务类 + * + * @author haoxr + * @since 2.3.0 + */ +@Component +@ConditionalOnProperty(value = "oss.type", havingValue = "aliyun") +@ConfigurationProperties(prefix = "oss.aliyun") +@RequiredArgsConstructor +@Data +public class AliyunFileService implements FileService { + /** + * 服务Endpoint + */ + private String endpoint; + /** + * 访问凭据 + */ + private String accessKeyId; + /** + * 凭据密钥 + */ + private String accessKeySecret; + /** + * 存储桶名称 + */ + private String bucketName; + + private OSS aliyunOssClient; + + @PostConstruct + public void init() { + aliyunOssClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret); + } + + @Override + @SneakyThrows + public FileInfo uploadFile(MultipartFile file) { + + // 获取文件名称 + String originalFilename = file.getOriginalFilename(); + // 生成文件名(日期文件夹) + String suffix = FileUtil.getSuffix(originalFilename); + String uuid = IdUtil.simpleUUID(); + String fileName = DateUtil.format(LocalDateTime.now(), "yyyyMMdd") + "/" + uuid + "." + suffix; + // try-with-resource 语法糖自动释放流 + try (InputStream inputStream = file.getInputStream()) { + + // 设置上传文件的元信息,例如Content-Type + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentType(file.getContentType()); + // 创建PutObjectRequest对象,指定Bucket名称、对象名称和输入流 + PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, fileName, inputStream, metadata); + // 上传文件 + aliyunOssClient.putObject(putObjectRequest); + } catch (Exception e) { + throw new RuntimeException("文件上传失败"); + } + // 获取文件访问路径 + String fileUrl = "https://" + bucketName + "." + endpoint + "/" + fileName; + FileInfo fileInfo = new FileInfo(); + fileInfo.setName(originalFilename); + fileInfo.setUrl(fileUrl); + return fileInfo; + } + + @Override + public boolean deleteFile(String filePath) { + Assert.notBlank(filePath, "删除文件路径不能为空"); + String fileHost = "https://" + bucketName + "." + endpoint; // 文件主机域名 + String fileName = filePath.substring(fileHost.length() + 1); // +1 是/占一个字符,截断左闭右开 + aliyunOssClient.deleteObject(bucketName, fileName); + return true; + } +} diff --git a/src/main/java/com/youlai/boot/file/service/impl/LocalFileService.java b/src/main/java/com/youlai/boot/file/service/impl/LocalFileService.java new file mode 100644 index 0000000..c9239db --- /dev/null +++ b/src/main/java/com/youlai/boot/file/service/impl/LocalFileService.java @@ -0,0 +1,92 @@ +package com.youlai.boot.file.service.impl; + +import cn.hutool.core.date.DatePattern; +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.IdUtil; +import com.youlai.boot.file.model.FileInfo; +import com.youlai.boot.file.service.FileService; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.InputStream; +import java.time.LocalDateTime; + +/** + * 本地存储服务类 + * + * @author Theo + * @since 2024-12-09 17:11 + */ +@Data +@Slf4j +@Component +@ConditionalOnProperty(value = "oss.type", havingValue = "local") +@ConfigurationProperties(prefix = "oss.local") +@RequiredArgsConstructor +public class LocalFileService implements FileService { + + @Value("${oss.local.storage-path}") + private String storagePath; + + /** + * 上传文件方法 + * + * @param file 表单文件对象 + * @return 文件信息 + */ + @Override + public FileInfo uploadFile(MultipartFile file) { + // 获取文件名 + String originalFilename = file.getOriginalFilename(); + // 获取文件后缀 + String suffix = FileUtil.getSuffix(originalFilename); + // 生成uuid + 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; + // try-with-resource 语法糖自动释放流 + try (InputStream inputStream = file.getInputStream()) { + // 上传文件 + FileUtil.writeFromStream(inputStream, filePrefix + folder + File.separator + fileName); + } catch (Exception e) { + log.error("文件上传失败", e); + throw new RuntimeException("文件上传失败"); + } + // 获取文件访问路径,因为这里是本地存储,所以直接返回文件的相对路径,需要前端自行处理访问前缀 + String fileUrl = File.separator + folder + File.separator + fileName; + FileInfo fileInfo = new FileInfo(); + fileInfo.setName(originalFilename); + fileInfo.setUrl(fileUrl); + return fileInfo; + } + + + /** + * 删除文件 + * @param filePath 文件完整URL + * @return 是否删除成功 + */ + @Override + public boolean deleteFile(String filePath) { + //判断文件是否为空 + if (filePath == null || filePath.isEmpty()) { + return false; + } + // 判断filepath是否为文件夹 + if (FileUtil.isDirectory(storagePath + filePath)) { + // 禁止删除文件夹 + return false; + } + // 删除文件 + return FileUtil.del(storagePath + filePath); + } +} diff --git a/src/main/java/com/youlai/boot/file/service/impl/MinioFileService.java b/src/main/java/com/youlai/boot/file/service/impl/MinioFileService.java new file mode 100644 index 0000000..03e5a32 --- /dev/null +++ b/src/main/java/com/youlai/boot/file/service/impl/MinioFileService.java @@ -0,0 +1,210 @@ +package com.youlai.boot.file.service.impl; + +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import com.youlai.boot.common.exception.BusinessException; +import com.youlai.boot.common.result.ResultCode; +import com.youlai.boot.file.model.FileInfo; +import com.youlai.boot.file.service.FileService; +import io.minio.*; +import io.minio.http.Method; +import jakarta.annotation.PostConstruct; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; +import java.time.LocalDateTime; + +/** + * MinIO 文件上传服务类 + * + * @author Ray.Hao + * @since 2023/6/2 + */ +@Component +@ConditionalOnProperty(value = "oss.type", havingValue = "minio") +@ConfigurationProperties(prefix = "oss.minio") +@RequiredArgsConstructor +@Data +@Slf4j +public class MinioFileService implements FileService { + + /** + * 服务Endpoint + */ + private String endpoint; + /** + * 访问凭据 + */ + private String accessKey; + /** + * 凭据密钥 + */ + private String secretKey; + /** + * 存储桶名称 + */ + private String bucketName; + /** + * 自定义域名 + */ + private String customDomain; + + private MinioClient minioClient; + + // 依赖注入完成之后执行初始化 + @PostConstruct + public void init() { + minioClient = MinioClient.builder() + .endpoint(endpoint) + .credentials(accessKey, secretKey) + .build(); + // 创建存储桶(存储桶不存在) + // createBucketIfAbsent(bucketName); + } + + + /** + * 上传文件 + * + * @param file 表单文件对象 + * @return 文件信息 + */ + @Override + public FileInfo uploadFile(MultipartFile file) { + + // 创建存储桶(存储桶不存在),如果有搭建好的minio服务,建议放在init方法中 + createBucketIfAbsent(bucketName); + + // 文件原生名称 + String originalFilename = file.getOriginalFilename(); + // 文件后缀 + String suffix = FileUtil.getSuffix(originalFilename); + // 文件夹名称 + String dateFolder = DateUtil.format(LocalDateTime.now(), "yyyyMMdd"); + // 文件名称 + String fileName = IdUtil.simpleUUID() + "." + suffix; + + // try-with-resource 语法糖自动释放流 + try (InputStream inputStream = file.getInputStream()) { + // 文件上传 + PutObjectArgs putObjectArgs = PutObjectArgs.builder() + .bucket(bucketName) + .object(dateFolder + "/"+ fileName) + .contentType(file.getContentType()) + .stream(inputStream, inputStream.available(), -1) + .build(); + minioClient.putObject(putObjectArgs); + + // 返回文件路径 + String fileUrl; + // 未配置自定义域名 + if (StrUtil.isBlank(customDomain)) { + // 获取文件URL + GetPresignedObjectUrlArgs getPresignedObjectUrlArgs = GetPresignedObjectUrlArgs.builder() + .bucket(bucketName) + .object(dateFolder + "/"+ fileName) + .method(Method.GET) + .build(); + + fileUrl = minioClient.getPresignedObjectUrl(getPresignedObjectUrlArgs); + fileUrl = fileUrl.substring(0, fileUrl.indexOf("?")); + } else { + // 配置自定义文件路径域名 + fileUrl = customDomain + "/"+ bucketName + "/"+ dateFolder + "/"+ fileName; + } + + FileInfo fileInfo = new FileInfo(); + fileInfo.setName(originalFilename); + fileInfo.setUrl(fileUrl); + return fileInfo; + } catch (Exception e) { + log.error("上传文件失败", e); + throw new BusinessException(ResultCode.UPLOAD_FILE_EXCEPTION, e.getMessage()); + } + } + + + /** + * 删除文件 + * + * @param filePath 文件完整路径 + * @return 是否删除成功 + */ + @Override + public boolean deleteFile(String filePath) { + Assert.notBlank(filePath, "删除文件路径不能为空"); + try { + String fileName; + if (StrUtil.isNotBlank(customDomain)) { + // https://oss.youlai.tech/default/20221120/test.jpg → 20221120/websocket.jpg + fileName = filePath.substring(customDomain.length() + 1 + bucketName.length() + 1); // 两个/占了2个字符长度 + } else { + // http://localhost:9000/default/20221120/test.jpg → 20221120/websocket.jpg + fileName = filePath.substring(endpoint.length() + 1 + bucketName.length() + 1); + } + RemoveObjectArgs removeObjectArgs = RemoveObjectArgs.builder() + .bucket(bucketName) + .object(fileName) + .build(); + + minioClient.removeObject(removeObjectArgs); + return true; + } catch (Exception e) { + log.error("删除文件失败", e); + throw new BusinessException(ResultCode.DELETE_FILE_EXCEPTION, e.getMessage()); + } + } + + + /** + * PUBLIC桶策略 + * 如果不配置,则新建的存储桶默认是PRIVATE,则存储桶文件会拒绝访问 Access Denied + * + * @param bucketName 存储桶名称 + * @return 存储桶策略 + */ + private static String publicBucketPolicy(String bucketName) { + // AWS的S3存储桶策略 JSON 格式 https://docs.aws.amazon.com/zh_cn/AmazonS3/latest/userguide/example-bucket-policies.html + return "{\"Version\":\"2012-10-17\"," + + "\"Statement\":[{\"Effect\":\"Allow\"," + + "\"Principal\":{\"AWS\":[\"*\"]}," + + "\"Action\":[\"s3:ListBucketMultipartUploads\",\"s3:GetBucketLocation\",\"s3:ListBucket\"]," + + "\"Resource\":[\"arn:aws:s3:::" + bucketName + "\"]}," + + "{\"Effect\":\"Allow\"," + "\"Principal\":{\"AWS\":[\"*\"]}," + + "\"Action\":[\"s3:ListMultipartUploadParts\",\"s3:PutObject\",\"s3:AbortMultipartUpload\",\"s3:DeleteObject\",\"s3:GetObject\"]," + + "\"Resource\":[\"arn:aws:s3:::" + bucketName + "/*\"]}]}"; + } + + /** + * 创建存储桶(存储桶不存在) + * + * @param bucketName 存储桶名称 + */ + @SneakyThrows + private void createBucketIfAbsent(String bucketName) { + BucketExistsArgs bucketExistsArgs = BucketExistsArgs.builder().bucket(bucketName).build(); + if (!minioClient.bucketExists(bucketExistsArgs)) { + MakeBucketArgs makeBucketArgs = MakeBucketArgs.builder().bucket(bucketName).build(); + + minioClient.makeBucket(makeBucketArgs); + + // 设置存储桶访问权限为PUBLIC, 如果不配置,则新建的存储桶默认是PRIVATE,则存储桶文件会拒绝访问 Access Denied + SetBucketPolicyArgs setBucketPolicyArgs = SetBucketPolicyArgs + .builder() + .bucket(bucketName) + .config(publicBucketPolicy(bucketName)) + .build(); + minioClient.setBucketPolicy(setBucketPolicyArgs); + } + } +} diff --git a/src/main/java/com/youlai/boot/framework/apidoc/Knife4jOpenApiCustomizer.java b/src/main/java/com/youlai/boot/framework/apidoc/Knife4jOpenApiCustomizer.java new file mode 100644 index 0000000..6ab80e5 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/apidoc/Knife4jOpenApiCustomizer.java @@ -0,0 +1,150 @@ +package com.youlai.boot.framework.apidoc; + + +import com.github.xiaoymin.knife4j.annotations.ApiSupport; +import com.github.xiaoymin.knife4j.core.conf.ExtensionsConstants; +import com.github.xiaoymin.knife4j.core.conf.GlobalConstants; +import com.github.xiaoymin.knife4j.spring.configuration.Knife4jProperties; +import com.github.xiaoymin.knife4j.spring.configuration.Knife4jSetting; +import com.github.xiaoymin.knife4j.spring.extension.OpenApiExtensionResolver; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.models.OpenAPI; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.ArrayUtils; +import org.springdoc.core.customizers.GlobalOpenApiCustomizer; +import org.springdoc.core.properties.SpringDocConfigProperties; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.core.type.filter.AnnotationTypeFilter; +import org.springframework.util.CollectionUtils; +import org.springframework.web.bind.annotation.RestController; + +import java.lang.annotation.Annotation; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 增强扩展属性支持 + * @since 4.1.0 + * @author xiaoymin@foxmail.com + * 2022/12/11 22:40 + */ +@Primary +@Configuration +@Slf4j +public class Knife4jOpenApiCustomizer extends com.github.xiaoymin.knife4j.spring.extension.Knife4jOpenApiCustomizer implements GlobalOpenApiCustomizer { + final Knife4jProperties knife4jProperties; + final SpringDocConfigProperties properties; + public Knife4jOpenApiCustomizer(Knife4jProperties knife4jProperties, SpringDocConfigProperties properties) { + super(knife4jProperties,properties); + this.knife4jProperties = knife4jProperties; + this.properties = properties; + } + + @Override + public void customise(OpenAPI openApi) { + log.debug("Knife4j OpenApiCustomizer"); + if (knife4jProperties.isEnable()) { + Knife4jSetting setting = knife4jProperties.getSetting(); + OpenApiExtensionResolver openApiExtensionResolver = new OpenApiExtensionResolver(setting, knife4jProperties.getDocuments()); + // 解析初始化 + openApiExtensionResolver.start(); + Map objectMap = new HashMap<>(); + objectMap.put(GlobalConstants.EXTENSION_OPEN_SETTING_NAME, setting); + objectMap.put(GlobalConstants.EXTENSION_OPEN_MARKDOWN_NAME, openApiExtensionResolver.getMarkdownFiles()); + openApi.addExtension(GlobalConstants.EXTENSION_OPEN_API_NAME, objectMap); + addOrderExtension(openApi); + } + } + + /** + * 往OpenAPI内tags字段添加x-order属性 + * + * @param openApi openApi + */ + + private void addOrderExtension(OpenAPI openApi) { + if (CollectionUtils.isEmpty(properties.getGroupConfigs())) { + return; + } + // 获取包扫描路径 + Set packagesToScan = + properties.getGroupConfigs().stream() + .map(SpringDocConfigProperties.GroupConfig::getPackagesToScan) + .filter(toScan -> !CollectionUtils.isEmpty(toScan)) + .flatMap(List::stream) + .collect(Collectors.toSet()); + if (CollectionUtils.isEmpty(packagesToScan)) { + return; + } + // 扫描包下被ApiSupport注解的RestController Class + Set> classes = + packagesToScan.stream() + .map(packageToScan -> scanPackageByAnnotation(packageToScan, RestController.class)) + .flatMap(Set::stream) + .filter(clazz -> clazz.isAnnotationPresent(ApiSupport.class)) + .collect(Collectors.toSet()); + if (!CollectionUtils.isEmpty(classes)) { + // ApiSupport oder值存入tagSortMap + Map tagOrderMap = new HashMap<>(); + classes.forEach( + clazz -> { + Tag tag = getTag(clazz); + if (Objects.nonNull(tag)) { + ApiSupport apiSupport = clazz.getAnnotation(ApiSupport.class); + tagOrderMap.putIfAbsent(tag.name(), apiSupport.order()); + } + }); + // 往openApi tags字段添加x-order增强属性 + if (openApi.getTags() != null) { + openApi + .getTags() + .forEach( + tag -> { + if (tagOrderMap.containsKey(tag.getName())) { + tag.addExtension( + ExtensionsConstants.EXTENSION_ORDER, tagOrderMap.get(tag.getName())); + } + }); + } + } + } + + private Tag getTag(Class clazz) { + // 从类上获取 + Tag tag = clazz.getAnnotation(Tag.class); + if (Objects.isNull(tag)) { + // 从接口上获取 + Class[] interfaces = clazz.getInterfaces(); + if (ArrayUtils.isNotEmpty(interfaces)) { + for (Class interfaceClazz : interfaces) { + Tag anno = interfaceClazz.getAnnotation(Tag.class); + if (Objects.nonNull(anno)) { + tag = anno; + break; + } + } + } + } + return tag; + } + + private Set> scanPackageByAnnotation( + String packageName, final Class annotationClass) { + ClassPathScanningCandidateComponentProvider scanner = + new ClassPathScanningCandidateComponentProvider(false); + scanner.addIncludeFilter(new AnnotationTypeFilter(annotationClass)); + Set> classes = new HashSet<>(); + for (BeanDefinition beanDefinition : scanner.findCandidateComponents(packageName)) { + try { + Class clazz = Class.forName(beanDefinition.getBeanClassName()); + classes.add(clazz); + } catch (ClassNotFoundException ignore) { + + } + } + return classes; + } +} diff --git a/src/main/java/com/youlai/boot/framework/apidoc/OpenApiConfig.java b/src/main/java/com/youlai/boot/framework/apidoc/OpenApiConfig.java new file mode 100644 index 0000000..066c69e --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/apidoc/OpenApiConfig.java @@ -0,0 +1,106 @@ +package com.youlai.boot.framework.apidoc; + +import cn.hutool.core.util.ArrayUtil; +import com.youlai.boot.framework.security.config.SecurityProperties; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springdoc.core.customizers.GlobalOpenApiCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.http.HttpHeaders; +import org.springframework.util.AntPathMatcher; + +import java.util.stream.Stream; + +/** + * OpenAPI 接口文档配置 + * + * @author Ray.Hao + * @see knife4j 快速开始 + * @since 2023/2/17 + */ +@Configuration +@RequiredArgsConstructor +@Slf4j +public class OpenApiConfig { + + private final Environment environment; + + private final SecurityProperties securityProperties; + + /** + * 接口文档信息 + */ + @Bean + public OpenAPI openApi() { + + String appVersion = environment.getProperty("project.version", "1.0.0"); + + return new OpenAPI() + .info(new Info() + .title("管理系统 API 文档") + .description("本文档涵盖管理系统的所有API接口,包括登录认证、用户管理、角色管理、部门管理等功能模块,提供详细的接口说明和使用指南。") + .version(appVersion) + .license(new License() + .name("Apache License 2.0") + .url("http://www.apache.org/licenses/LICENSE-2.0") + ) + .contact(new Contact() + .name("youlai") + .email("youlaitech@163.com") + .url("https://www.youlai.tech") + ) + ) + // 配置全局鉴权参数-Authorize + .components(new Components() + .addSecuritySchemes(HttpHeaders.AUTHORIZATION, + new SecurityScheme() + .name(HttpHeaders.AUTHORIZATION) + .type(SecurityScheme.Type.APIKEY) + .in(SecurityScheme.In.HEADER) + .scheme("Bearer") + .bearerFormat("JWT") + ) + ); + } + + + /** + * 全局自定义扩展 + */ + @Bean + public GlobalOpenApiCustomizer globalOpenApiCustomizer() { + return openApi -> { + // 全局添加Authorization + if (openApi.getPaths() != null) { + openApi.getPaths().forEach((path, pathItem) -> { + + // 忽略认证的请求无需携带 Authorization + String[] ignoreUrls = securityProperties.getIgnoreUrls(); + if (ArrayUtil.isNotEmpty(ignoreUrls)) { + // Ant 匹配忽略的路径,不添加Authorization + AntPathMatcher antPathMatcher = new AntPathMatcher(); + if (Stream.of(ignoreUrls).anyMatch(ignoreUrl -> antPathMatcher.match(ignoreUrl, path))) { + return; + } + } + + // 其他接口统一添加Authorization + pathItem.readOperations() + .forEach(operation -> + operation.addSecurityItem(new SecurityRequirement().addList(HttpHeaders.AUTHORIZATION)) + ); + }); + } + }; + } + +} diff --git a/src/main/java/com/youlai/boot/framework/cache/CaffeineConfig.java b/src/main/java/com/youlai/boot/framework/cache/CaffeineConfig.java new file mode 100644 index 0000000..24c494d --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/cache/CaffeineConfig.java @@ -0,0 +1,37 @@ +package com.youlai.boot.framework.cache; + +import com.github.benmanes.caffeine.cache.Caffeine; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.CacheManager; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * caffeine缓存配置 + * + * @author Theo + * @since 2025-01-22 17:40:23 + */ +@Slf4j +@Configuration +public class CaffeineConfig { + + @Value("${spring.cache.caffeine.spec}") + private String caffeineSpec; + + /** + * 缓存管理器 + * + * @return CacheManager 缓存管理器 + */ + @Bean + public CacheManager cacheManager() { + CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager(); + Caffeine caffeineBuilder = Caffeine.from(caffeineSpec); + caffeineCacheManager.setCaffeine(caffeineBuilder); + return caffeineCacheManager; + } +} + diff --git a/src/main/java/com/youlai/boot/framework/cache/RedisCacheConfig.java b/src/main/java/com/youlai/boot/framework/cache/RedisCacheConfig.java new file mode 100644 index 0000000..06b3070 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/cache/RedisCacheConfig.java @@ -0,0 +1,74 @@ +package com.youlai.boot.framework.cache; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.cache.autoconfigure.CacheProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.cache.RedisCacheWriter; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.RedisSerializer; + +/** + * Redis 缓存配置 + * + * @author Ray.Hao + * @since 2023/12/4 + */ +@EnableCaching +@EnableConfigurationProperties(CacheProperties.class) +@Configuration +@ConditionalOnProperty(name = "spring.cache.enabled") // xxl.job.enabled = true 才会自动装配 +public class RedisCacheConfig { + + /** + * 自定义 RedisCacheManager + *

+ * 修改 Redis 序列化方式,默认 JdkSerializationRedisSerializer + * + * @param redisConnectionFactory {@link RedisConnectionFactory} + * @param cacheProperties {@link CacheProperties} + * @return {@link RedisCacheManager} + */ + @Bean + public RedisCacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory, CacheProperties cacheProperties){ + return RedisCacheManager.builder(RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory)) + .cacheDefaults(redisCacheConfiguration(cacheProperties)) + .build(); + } + + /** + * 自定义 RedisCacheConfiguration + * + * @param cacheProperties {@link CacheProperties} + * @return {@link RedisCacheConfiguration} + */ + @Bean + RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) { + + RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig(); + + config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(RedisSerializer.string())); + config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(RedisSerializer.json())); + + CacheProperties.Redis redisProperties = cacheProperties.getRedis(); + + if (redisProperties.getTimeToLive() != null) { + config = config.entryTtl(redisProperties.getTimeToLive()); + } + if (!redisProperties.isCacheNullValues()) { + config = config.disableCachingNullValues(); + } + if (!redisProperties.isUseKeyPrefix()) { + config = config.disableKeyPrefix(); + } + // 覆盖默认key双冒号 CacheKeyPrefix#prefixed + config = config.computePrefixWith(name -> name + ":"); + return config; + } + +} diff --git a/src/main/java/com/youlai/boot/framework/cache/RedisConfig.java b/src/main/java/com/youlai/boot/framework/cache/RedisConfig.java new file mode 100644 index 0000000..61e6a15 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/cache/RedisConfig.java @@ -0,0 +1,56 @@ +package com.youlai.boot.framework.cache; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializer; + +/** + * Redis 配置 + * + * @author Ray.Hao + * @since 2023/5/15 + */ +@Configuration +public class RedisConfig { + + /** + * 自定义 RedisTemplate + *

+ * 修改 Redis 序列化方式,默认 JdkSerializationRedisSerializer + * + * @param redisConnectionFactory {@link RedisConnectionFactory} + * @return {@link RedisTemplate} + */ + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { + + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory); + + // Key 使用 String 序列化 + redisTemplate.setKeySerializer(RedisSerializer.string()); + redisTemplate.setHashKeySerializer(RedisSerializer.string()); + + // Value 使用自定义 JSON 序列化(不写入类型信息,避免 HashSet 等集合被序列化成带 @class 的结构) + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + // 禁用类型信息写入,避免集合类型名被当成元素 + objectMapper.disableDefaultTyping(); + + Jackson2JsonRedisSerializer jsonSerializer = new Jackson2JsonRedisSerializer<>(objectMapper, Object.class); + + redisTemplate.setValueSerializer(jsonSerializer); + redisTemplate.setHashValueSerializer(jsonSerializer); + + redisTemplate.afterPropertiesSet(); + return redisTemplate; + } + +} diff --git a/src/main/java/com/youlai/boot/framework/captcha/config/CaptchaConfig.java b/src/main/java/com/youlai/boot/framework/captcha/config/CaptchaConfig.java new file mode 100644 index 0000000..2a890e6 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/captcha/config/CaptchaConfig.java @@ -0,0 +1,54 @@ +package com.youlai.boot.framework.captcha.config; + +import cn.hutool.captcha.generator.CodeGenerator; +import cn.hutool.captcha.generator.MathGenerator; +import cn.hutool.captcha.generator.RandomGenerator; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.awt.*; + +/** + * 验证码自动装配配置 + * + * @author haoxr + * @since 2023/11/24 + */ +@Configuration +public class CaptchaConfig { + + @Autowired + private CaptchaProperties captchaProperties; + + /** + * 验证码文字生成器 + * + * @return CodeGenerator + */ + @Bean + public CodeGenerator codeGenerator() { + String codeType = captchaProperties.getCode().getType(); + int codeLength = captchaProperties.getCode().getLength(); + if ("math".equalsIgnoreCase(codeType)) { + return new MathGenerator(codeLength, false); + } else if ("random".equalsIgnoreCase(codeType)) { + return new RandomGenerator(codeLength); + } else { + throw new IllegalArgumentException("Invalid captcha codegen type: " + codeType); + } + } + + /** + * 验证码字体 + */ + @Bean + public Font captchaFont() { + String fontName = captchaProperties.getFont().getName(); + int fontSize = captchaProperties.getFont().getSize(); + int fontWight = captchaProperties.getFont().getWeight(); + return new Font(fontName, fontWight, fontSize); + } + + +} diff --git a/src/main/java/com/youlai/boot/framework/captcha/config/CaptchaProperties.java b/src/main/java/com/youlai/boot/framework/captcha/config/CaptchaProperties.java new file mode 100644 index 0000000..1ea87bf --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/captcha/config/CaptchaProperties.java @@ -0,0 +1,92 @@ +package com.youlai.boot.framework.captcha.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 验证码 属性配置 + * + * @author Ray.Hao + * @since 2023/11/24 + */ +@Component +@ConfigurationProperties(prefix = "captcha") +@Data +public class CaptchaProperties { + + /** + * 验证码类型 circle-圆圈干扰验证码|gif-Gif验证码|line-干扰线验证码|shear-扭曲干扰验证码 + */ + private String type; + + /** + * 验证码图片宽度 + */ + private int width; + /** + * 验证码图片高度 + */ + private int height; + + /** + * 干扰线数量 + */ + private int interfereCount; + + /** + * 文本透明度 + */ + private Float textAlpha; + + /** + * 验证码过期时间,单位:秒 + */ + private Long expireSeconds; + + /** + * 验证码字符配置 + */ + private CodeProperties code; + + /** + * 验证码字体 + */ + private FontProperties font; + + /** + * 验证码字符配置 + */ + @Data + public static class CodeProperties { + /** + * 验证码字符类型 math-算术|random-随机字符串 + */ + private String type; + /** + * 验证码字符长度,type=算术时,表示运算位数(1:个位数 2:十位数);type=随机字符时,表示字符个数 + */ + private int length; + } + + /** + * 验证码字体配置 + */ + @Data + public static class FontProperties { + /** + * 字体名称 + */ + private String name; + /** + * 字体样式 0-普通|1-粗体|2-斜体 + */ + private int weight; + /** + * 字体大小 + */ + private int size; + } + + +} diff --git a/src/main/java/com/youlai/boot/framework/captcha/exception/CaptchaException.java b/src/main/java/com/youlai/boot/framework/captcha/exception/CaptchaException.java new file mode 100644 index 0000000..61b95be --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/captcha/exception/CaptchaException.java @@ -0,0 +1,19 @@ +package com.youlai.boot.framework.captcha.exception; + +import com.youlai.boot.common.result.ResultCode; +import lombok.Getter; + +/** + * 验证码异常 + */ +@Getter +public class CaptchaException extends RuntimeException { + + private final ResultCode resultCode; + + public CaptchaException(ResultCode resultCode) { + super(resultCode.getMsg()); + this.resultCode = resultCode; + } + +} diff --git a/src/main/java/com/youlai/boot/framework/captcha/model/CaptchaInfo.java b/src/main/java/com/youlai/boot/framework/captcha/model/CaptchaInfo.java new file mode 100644 index 0000000..ad84530 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/captcha/model/CaptchaInfo.java @@ -0,0 +1,25 @@ +package com.youlai.boot.framework.captcha.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 验证码信息 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "验证码信息") +public class CaptchaInfo { + + @Schema(description = "验证码缓存ID") + private String captchaId; + + @Schema(description = "验证码图片Base64字符串") + private String captchaBase64; + +} diff --git a/src/main/java/com/youlai/boot/framework/captcha/service/CaptchaService.java b/src/main/java/com/youlai/boot/framework/captcha/service/CaptchaService.java new file mode 100644 index 0000000..32ff041 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/captcha/service/CaptchaService.java @@ -0,0 +1,102 @@ +package com.youlai.boot.framework.captcha.service; + +import cn.hutool.captcha.AbstractCaptcha; +import cn.hutool.captcha.CaptchaUtil; +import cn.hutool.captcha.generator.CodeGenerator; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import com.youlai.boot.common.constant.RedisConstants; +import com.youlai.boot.common.enums.CaptchaTypeEnum; +import com.youlai.boot.common.result.ResultCode; +import com.youlai.boot.framework.captcha.config.CaptchaProperties; +import com.youlai.boot.framework.captcha.exception.CaptchaException; +import com.youlai.boot.framework.captcha.model.CaptchaInfo; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.awt.Font; +import java.util.concurrent.TimeUnit; + +/** + * 验证码服务 + */ +@Service +@RequiredArgsConstructor +public class CaptchaService { + + private final RedisTemplate redisTemplate; + private final CaptchaProperties captchaProperties; + private final CodeGenerator codeGenerator; + private final Font captchaFont; + + /** + * 生成验证码 + */ + public CaptchaInfo generate() { + String captchaType = captchaProperties.getType(); + int width = captchaProperties.getWidth(); + int height = captchaProperties.getHeight(); + int interfereCount = captchaProperties.getInterfereCount(); + int codeLength = captchaProperties.getCode().getLength(); + + AbstractCaptcha captcha; + if (CaptchaTypeEnum.CIRCLE.name().equalsIgnoreCase(captchaType)) { + captcha = CaptchaUtil.createCircleCaptcha(width, height, codeLength, interfereCount); + } else if (CaptchaTypeEnum.GIF.name().equalsIgnoreCase(captchaType)) { + captcha = CaptchaUtil.createGifCaptcha(width, height, codeLength); + } else if (CaptchaTypeEnum.LINE.name().equalsIgnoreCase(captchaType)) { + captcha = CaptchaUtil.createLineCaptcha(width, height, codeLength, interfereCount); + } else if (CaptchaTypeEnum.SHEAR.name().equalsIgnoreCase(captchaType)) { + captcha = CaptchaUtil.createShearCaptcha(width, height, codeLength, interfereCount); + } else { + throw new IllegalArgumentException("Invalid captcha type: " + captchaType); + } + + captcha.setGenerator(codeGenerator); + captcha.setTextAlpha(captchaProperties.getTextAlpha()); + captcha.setFont(captchaFont); + + String captchaCode = captcha.getCode(); + String imageBase64Data = captcha.getImageBase64Data(); + + String captchaId = IdUtil.fastSimpleUUID(); + redisTemplate.opsForValue().set( + StrUtil.format(RedisConstants.Captcha.IMAGE_CODE, captchaId), + captchaCode, + captchaProperties.getExpireSeconds(), + TimeUnit.SECONDS + ); + + return CaptchaInfo.builder() + .captchaId(captchaId) + .captchaBase64(imageBase64Data) + .build(); + } + + /** + * 校验验证码,失败抛异常 + * + * @param captchaId 验证码ID + * @param captchaCode 用户输入的验证码 + * @throws CaptchaException 验证码错误或过期 + */ + public void validate(String captchaId, String captchaCode) { + if (StrUtil.isBlank(captchaId) || StrUtil.isBlank(captchaCode)) { + throw new CaptchaException(ResultCode.USER_VERIFICATION_CODE_ERROR); + } + + String cacheKey = StrUtil.format(RedisConstants.Captcha.IMAGE_CODE, captchaId); + String cachedCode = (String) redisTemplate.opsForValue().get(cacheKey); + if (cachedCode == null) { + throw new CaptchaException(ResultCode.USER_VERIFICATION_CODE_EXPIRED); + } + + if (!codeGenerator.verify(cachedCode, captchaCode)) { + throw new CaptchaException(ResultCode.USER_VERIFICATION_CODE_ERROR); + } + + redisTemplate.delete(cacheKey); + } + +} diff --git a/src/main/java/com/youlai/boot/framework/integration/mail/config/MailConfig.java b/src/main/java/com/youlai/boot/framework/integration/mail/config/MailConfig.java new file mode 100644 index 0000000..687d275 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/integration/mail/config/MailConfig.java @@ -0,0 +1,50 @@ +package com.youlai.boot.framework.integration.mail.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +import java.util.Properties; + +/** + * MailConfig 配置类,用于手动配置和注入 JavaMailSender。 + * 通过读取 MailProperties 类中配置的邮件相关属性来初始化 JavaMailSender。 + *

+ * 手动注入的原因是为了避免在使用 application-dev.yml 或其他非 application.yml 配置文件时, + * IDEA 提示无法找到 JavaMailSender 的 bean。 + * + * @author Ray + * @since 2024/8/17 + */ +@Configuration +@EnableConfigurationProperties(MailProperties.class) +public class MailConfig { + + private final MailProperties mailProperties; + + public MailConfig(MailProperties mailProperties) { + this.mailProperties = mailProperties; + } + + /** + * 创建并配置 JavaMailSender bean。 + * + * @return 配置好的 JavaMailSender 实例 + */ + @Bean + public JavaMailSender javaMailSender() { + JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); + mailSender.setHost(mailProperties.getHost()); + mailSender.setPort(mailProperties.getPort()); + mailSender.setUsername(mailProperties.getUsername()); + mailSender.setPassword(mailProperties.getPassword()); + + Properties properties = mailSender.getJavaMailProperties(); + properties.put("mail.smtp.auth", mailProperties.getProperties().getSmtp().isAuth()); + properties.put("mail.smtp.starttls.enable", mailProperties.getProperties().getSmtp().getStarttls().isEnable()); + + return mailSender; + } +} diff --git a/src/main/java/com/youlai/boot/framework/integration/mail/config/MailProperties.java b/src/main/java/com/youlai/boot/framework/integration/mail/config/MailProperties.java new file mode 100644 index 0000000..b8588b4 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/integration/mail/config/MailProperties.java @@ -0,0 +1,89 @@ +package com.youlai.boot.framework.integration.mail.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 邮件配置类,用于接收和存储邮件相关的配置属性。 + * + * @author Ray + * @since 2024/8/17 + */ +@ConfigurationProperties(prefix = "spring.mail") +@Data +public class MailProperties { + + /** + * 邮件服务器主机名或 IP 地址。 + * 例如:smtp.example.com + */ + private String host; + + /** + * 邮件服务器端口号。 + * 例如:587 + */ + private int port; + + /** + * 用于连接邮件服务器的用户名。 + * 例如:your_email@example.com + */ + private String username; + + /** + * 用于连接邮件服务器的密码。 + * 该密码应安全存储,不应在代码中硬编码。 + */ + private String password; + + /** + * 邮件发送者地址。 + */ + private String from; + + /** + * 邮件服务器的其他属性配置。 + * 这些配置通常用于进一步定制邮件发送行为。 + */ + private Properties properties = new Properties(); + + /** + * 内部类,用于封装邮件服务器的详细配置。 + * 包含 SMTP 相关的配置选项。 + */ + @Data + public static class Properties { + + /** + * SMTP 配置选项类。 + * 包含认证、加密等与 SMTP 协议相关的配置。 + */ + private Smtp smtp = new Smtp(); + + @Data + public static class Smtp { + + /** + * 是否启用 SMTP 认证。 + * 如果为 `true`,则需要提供有效的用户名和密码进行认证。 + */ + private boolean auth; + + /** + * STARTTLS 加密配置选项。 + */ + private StartTls starttls = new StartTls(); + + @Data + public static class StartTls { + + /** + * 是否启用 STARTTLS 加密。 + * 如果为 `true`,在发送邮件时将启用 STARTTLS 协议进行加密传输。 + */ + private boolean enable; + } + } + } +} diff --git a/src/main/java/com/youlai/boot/framework/integration/mail/service/MailService.java b/src/main/java/com/youlai/boot/framework/integration/mail/service/MailService.java new file mode 100644 index 0000000..dbca644 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/integration/mail/service/MailService.java @@ -0,0 +1,76 @@ +package com.youlai.boot.framework.integration.mail.service; + +import com.youlai.boot.framework.integration.mail.config.MailProperties; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.FileSystemResource; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; + +import java.io.File; + +/** + * 邮件服务 + * + * @author Ray.Hao + * @since 2024/8/17 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class MailService { + + private final JavaMailSender mailSender; + + private final MailProperties mailProperties; + + /** + * 发送简单文本邮件 + * + * @param to 收件人地址 + * @param subject 邮件主题 + * @param text 邮件内容 + */ + public void sendMail(String to, String subject, String text) { + try { + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(mailProperties.getFrom()); + message.setTo(to); + message.setSubject(subject); + message.setText(text); + mailSender.send(message); + } catch (Exception e) { + log.error("发送邮件失败{}", e.getMessage()); + } + } + + /** + * 发送带附件的邮件 + * + * @param to 收件人地址 + * @param subject 邮件主题 + * @param text 邮件内容 + * @param filePath 附件路径 + */ + public void sendMailWithAttachment(String to, String subject, String text, String filePath) { + MimeMessage message = mailSender.createMimeMessage(); + try { + MimeMessageHelper helper = new MimeMessageHelper(message, true); + helper.setFrom(mailProperties.getFrom()); + helper.setTo(to); + helper.setSubject(subject); + helper.setText(text, true); // true 表示支持HTML内容 + + FileSystemResource file = new FileSystemResource(new File(filePath)); + helper.addAttachment(file.getFilename(), file); + + mailSender.send(message); + } catch (MessagingException e) { + log.error("发送带附件的邮件失败{}", e.getMessage()); + } + } +} diff --git a/src/main/java/com/youlai/boot/framework/integration/sms/config/AliyunSmsProperties.java b/src/main/java/com/youlai/boot/framework/integration/sms/config/AliyunSmsProperties.java new file mode 100644 index 0000000..5bb590c --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/integration/sms/config/AliyunSmsProperties.java @@ -0,0 +1,50 @@ +package com.youlai.boot.framework.integration.sms.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.util.Map; + +/** + * 阿里云短信配置 + * + * @author Ray + * @since 2024/8/17 + */ +@Configuration +@ConfigurationProperties(prefix = "sms.aliyun") +@Data +public class AliyunSmsProperties { + + /** + * 阿里云账户的Access Key ID,用于API请求认证 + */ + private String accessKeyId; + + /** + *阿里云账户的Access Key Secret,用于API请求认证 + */ + private String accessKeySecret; + + /** + * 阿里云短信服务API的域名 eg: dysmsapi.aliyuncs.com + */ + private String domain; + + /** + * 阿里云服务的区域ID,如cn-shanghai + */ + private String regionId; + + /** + * 短信签名,必须是已经在阿里云短信服务中注册并通过审核的 + */ + private String signName; + + /** + * 短信模板集合 + */ + private Map templates; + +} diff --git a/src/main/java/com/youlai/boot/framework/integration/sms/enums/SmsTypeEnum.java b/src/main/java/com/youlai/boot/framework/integration/sms/enums/SmsTypeEnum.java new file mode 100644 index 0000000..ea062a5 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/integration/sms/enums/SmsTypeEnum.java @@ -0,0 +1,39 @@ +package com.youlai.boot.framework.integration.sms.enums; + +import com.youlai.boot.common.base.IBaseEnum; +import lombok.Getter; + +/** + * 短信类型枚举 + *

+ * value 值对应 application-*.yml 中的 sms.templates.* 配置 + * + * @author Ray.Hao + * @since 2.21.0 + */ +@Getter +public enum SmsTypeEnum implements IBaseEnum { + + /** + * 注册短信验证码 + */ + REGISTER("register", "注册短信验证码"), + + /** + * 登录短信验证码 + */ + LOGIN("login", "登录短信验证码"), + + /** + * 修改手机号短信验证码 + */ + CHANGE_MOBILE("change-mobile", "修改手机号短信验证码"); + + private final String value; + private final String label; + + SmsTypeEnum(String value, String label) { + this.value = value; + this.label = label; + } +} diff --git a/src/main/java/com/youlai/boot/framework/integration/sms/service/SmsService.java b/src/main/java/com/youlai/boot/framework/integration/sms/service/SmsService.java new file mode 100644 index 0000000..e56ecc0 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/integration/sms/service/SmsService.java @@ -0,0 +1,24 @@ +package com.youlai.boot.framework.integration.sms.service; + +import com.youlai.boot.framework.integration.sms.enums.SmsTypeEnum; + +import java.util.Map; + +/** + * 短信服务接口层 + * + * @author Ray.Hao + * @since 2024/8/17 + */ +public interface SmsService { + + /** + * 发送短信 + * + * @param mobile 手机号 13388886666 + * @param smsType 短信模板 SMS_194640010,模板内容:您的验证码为:${code},请在5分钟内使用 + * @param templateParams 模板参数 [{"code":"123456"}] ,用于替换短信模板中的变量 + * @return boolean 是否发送成功 + */ + boolean sendSms(String mobile, SmsTypeEnum smsType, Map templateParams); +} diff --git a/src/main/java/com/youlai/boot/framework/integration/sms/service/impl/AliyunSmsService.java b/src/main/java/com/youlai/boot/framework/integration/sms/service/impl/AliyunSmsService.java new file mode 100644 index 0000000..8053b8f --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/integration/sms/service/impl/AliyunSmsService.java @@ -0,0 +1,79 @@ +package com.youlai.boot.framework.integration.sms.service.impl; + +import cn.hutool.json.JSONUtil; +import com.aliyuncs.CommonRequest; +import com.aliyuncs.CommonResponse; +import com.aliyuncs.DefaultAcsClient; +import com.aliyuncs.IAcsClient; +import com.aliyuncs.exceptions.ClientException; +import com.aliyuncs.http.MethodType; +import com.aliyuncs.profile.DefaultProfile; +import com.youlai.boot.framework.integration.sms.config.AliyunSmsProperties; +import com.youlai.boot.framework.integration.sms.enums.SmsTypeEnum; +import com.youlai.boot.framework.integration.sms.service.SmsService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Map; + +/** + * 阿里云短信业务类 + * + * @author Ray + * @since 2024/8/17 + */ +@Service +@RequiredArgsConstructor +public class AliyunSmsService implements SmsService { + + private final AliyunSmsProperties aliyunSmsProperties; + + /** + * 发送短信验证码 + * + * @param mobile 手机号 13388886666 + * @param smsType 短信模板 SMS_194640010 + * @param templateParams 模板参数 [{"code":"123456"}] + * @return boolean 是否发送成功 + */ + @Override + public boolean sendSms(String mobile, SmsTypeEnum smsType, Map templateParams) { + + String templateCode = aliyunSmsProperties.getTemplates().get(smsType.getValue()); + + DefaultProfile profile = DefaultProfile.getProfile(aliyunSmsProperties.getRegionId(), + aliyunSmsProperties.getAccessKeyId(), aliyunSmsProperties.getAccessKeySecret()); + IAcsClient client = new DefaultAcsClient(profile); + + // 创建通用的请求对象 + CommonRequest request = new CommonRequest(); + // 指定请求方式 + request.setSysMethod(MethodType.POST); + // 短信api的请求地址(固定) + request.setSysDomain(aliyunSmsProperties.getDomain()); + // 签名算法版(固定) + request.setSysVersion("2017-05-25"); + // 请求 API 的名称(固定) + request.setSysAction("SendSms"); + // 指定地域名称 + request.putQueryParameter("RegionId", aliyunSmsProperties.getRegionId()); + // 要给哪个手机号发送短信 指定手机号 + request.putQueryParameter("PhoneNumbers", mobile); + // 您的申请签名 + request.putQueryParameter("SignName", aliyunSmsProperties.getSignName()); + // 您申请的模板 code + request.putQueryParameter("TemplateCode", templateCode); + + request.putQueryParameter("TemplateParam", JSONUtil.toJsonStr(templateParams)); + + try { + CommonResponse response = client.getCommonResponse(request); + return response.getHttpResponse().isSuccess(); + } catch (ClientException e) { + e.printStackTrace(); + } + return false; + } + + +} diff --git a/src/main/java/com/youlai/boot/framework/integration/wxma/WxMaConfig.java b/src/main/java/com/youlai/boot/framework/integration/wxma/WxMaConfig.java new file mode 100644 index 0000000..b706269 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/integration/wxma/WxMaConfig.java @@ -0,0 +1,37 @@ +package com.youlai.boot.framework.integration.wxma; + +import cn.binarywang.wx.miniapp.api.WxMaService; +import cn.binarywang.wx.miniapp.api.impl.WxMaServiceImpl; +import cn.binarywang.wx.miniapp.config.impl.WxMaDefaultConfigImpl; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 微信小程序配置 + * + * @author Ray.Hao + * @since 2024/01/01 + */ +@Configuration +@EnableConfigurationProperties(WxMaProperties.class) +public class WxMaConfig { + + /** + * 微信小程序服务 + * + * @param properties 微信小程序配置属性 + * @return {@link WxMaService} + */ + @Bean + public WxMaService wxMaService(WxMaProperties properties) { + WxMaDefaultConfigImpl config = new WxMaDefaultConfigImpl(); + config.setAppid(properties.getAppid()); + config.setSecret(properties.getSecret()); + + WxMaService service = new WxMaServiceImpl(); + service.setWxMaConfig(config); + return service; + } + +} diff --git a/src/main/java/com/youlai/boot/framework/integration/wxma/WxMaProperties.java b/src/main/java/com/youlai/boot/framework/integration/wxma/WxMaProperties.java new file mode 100644 index 0000000..4ed4f46 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/integration/wxma/WxMaProperties.java @@ -0,0 +1,23 @@ +package com.youlai.boot.framework.integration.wxma; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 微信小程序配置属性 + */ +@Data +@ConfigurationProperties(prefix = "wx.miniapp") +public class WxMaProperties { + + /** + * 小程序 AppID + */ + private String appid; + + /** + * 小程序 AppSecret + */ + private String secret; + +} diff --git a/src/main/java/com/youlai/boot/framework/job/XxlJobConfig.java b/src/main/java/com/youlai/boot/framework/job/XxlJobConfig.java new file mode 100644 index 0000000..9642ec2 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/job/XxlJobConfig.java @@ -0,0 +1,61 @@ +package com.youlai.boot.framework.job; + +import com.xxl.job.core.executor.impl.XxlJobSpringExecutor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * xxl-job config + * + * @author xuxueli 2017-04-28 + */ +@Configuration +@ConditionalOnProperty(name = "xxl.job.enabled") // xxl.job.enabled = true 才会自动装配 +@Slf4j +public class XxlJobConfig { + + @Value("${xxl.job.admin.addresses}") + private String adminAddresses; + + @Value("${xxl.job.accessToken}") + private String accessToken; + + @Value("${xxl.job.executor.appname}") + private String appname; + + @Value("${xxl.job.executor.address}") + private String address; + + @Value("${xxl.job.executor.ip}") + private String ip; + + @Value("${xxl.job.executor.port}") + private int port; + + @Value("${xxl.job.executor.logpath}") + private String logPath; + + @Value("${xxl.job.executor.logretentiondays}") + private int logRetentionDays; + + + @Bean + public XxlJobSpringExecutor xxlJobExecutor() { + log.info(">>>>>>>>>>> xxl-job config init."); + XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor(); + xxlJobSpringExecutor.setAdminAddresses(adminAddresses); + xxlJobSpringExecutor.setAppname(appname); + xxlJobSpringExecutor.setAddress(address); + xxlJobSpringExecutor.setIp(ip); + xxlJobSpringExecutor.setPort(port); + xxlJobSpringExecutor.setAccessToken(accessToken); + xxlJobSpringExecutor.setLogPath(logPath); + xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays); + + return xxlJobSpringExecutor; + } + +} diff --git a/src/main/java/com/youlai/boot/framework/mybatis/config/MybatisConfig.java b/src/main/java/com/youlai/boot/framework/mybatis/config/MybatisConfig.java new file mode 100644 index 0000000..a9e094c --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/mybatis/config/MybatisConfig.java @@ -0,0 +1,75 @@ +package com.youlai.boot.framework.mybatis.config; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.core.config.GlobalConfig; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.DataPermissionInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import com.youlai.boot.framework.mybatis.handler.MyMetaObjectHandler; +import com.youlai.boot.framework.mybatis.interceptor.MyDataPermissionHandler; +import org.apache.ibatis.mapping.DatabaseIdProvider; +import org.apache.ibatis.mapping.VendorDatabaseIdProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import java.util.Properties; + +/** + * mybatis-plus 配置类 + * + * @author Ray.Hao + * @since 2022/7/2 + */ +@Configuration +@EnableTransactionManagement +public class MybatisConfig { + + @Value("${app.db-type:mysql}") + private String dbType; + + /** + * 分页插件和数据权限插件 + */ + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + + // 数据权限 + interceptor.addInnerInterceptor(new DataPermissionInterceptor(new MyDataPermissionHandler())); + + // 分页插件,根据配置动态选择数据库类型 + DbType mpDbType = DbType.MYSQL; + String type = dbType == null ? "mysql" : dbType.toLowerCase(); + if ("postgres".equals(type) || "postgresql".equals(type)) { + mpDbType = DbType.POSTGRE_SQL; + } + interceptor.addInnerInterceptor(new PaginationInnerInterceptor(mpDbType)); + + return interceptor; + } + + /** + * 自动填充数据库创建人、创建时间、更新人、更新时间 + */ + @Bean + public GlobalConfig globalConfig() { + GlobalConfig globalConfig = new GlobalConfig(); + globalConfig.setMetaObjectHandler(new MyMetaObjectHandler()); + return globalConfig; + } + + /** + * 数据库类型自动识别 + */ + @Bean + public DatabaseIdProvider databaseIdProvider() { + DatabaseIdProvider databaseIdProvider = new VendorDatabaseIdProvider(); + Properties properties = new Properties(); + properties.setProperty("MySQL", "mysql"); + databaseIdProvider.setProperties(properties); + return databaseIdProvider; + } + +} diff --git a/src/main/java/com/youlai/boot/framework/mybatis/handler/MyMetaObjectHandler.java b/src/main/java/com/youlai/boot/framework/mybatis/handler/MyMetaObjectHandler.java new file mode 100644 index 0000000..75b6f41 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/mybatis/handler/MyMetaObjectHandler.java @@ -0,0 +1,44 @@ +package com.youlai.boot.framework.mybatis.handler; + +import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; +import lombok.RequiredArgsConstructor; +import org.apache.ibatis.reflection.MetaObject; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +/** + * mybatis-plus 字段自动填充 + *

+ * 支持自动填充创建时间、更新时间 + *

+ * + * @author Ray.Hao + * @since 2022/10/14 + */ +@Component +@RequiredArgsConstructor +public class MyMetaObjectHandler implements MetaObjectHandler { + + /** + * 新增填充创建时间、更新时间 + * + * @param metaObject 元数据 + */ + @Override + public void insertFill(MetaObject metaObject) { + this.strictInsertFill(metaObject, "createTime", LocalDateTime::now, LocalDateTime.class); + this.strictUpdateFill(metaObject, "updateTime", LocalDateTime::now, LocalDateTime.class); + } + + /** + * 更新填充更新时间 + * + * @param metaObject 元数据 + */ + @Override + public void updateFill(MetaObject metaObject) { + this.strictUpdateFill(metaObject, "updateTime", LocalDateTime::now, LocalDateTime.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 new file mode 100644 index 0000000..d2bd122 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/mybatis/interceptor/MyDataPermissionHandler.java @@ -0,0 +1,267 @@ +package com.youlai.boot.framework.mybatis.interceptor; + +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.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.util.SecurityUtils; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import net.sf.jsqlparser.expression.*; +import net.sf.jsqlparser.expression.operators.conditional.AndExpression; +import net.sf.jsqlparser.expression.operators.conditional.OrExpression; +import net.sf.jsqlparser.expression.operators.relational.EqualsTo; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; + +import java.lang.reflect.Method; +import java.util.List; + +/** + * 数据权限控制器 + *

+ * 支持多角色数据权限合并(并集策略): + * - 如果任一角色是 ALL,则跳过数据权限过滤 + * - 否则用 OR 连接各角色的数据权限条件 + *

+ * 使用 JSQLParser 构建 SQL 条件,避免字符串拼接,提高代码安全性和可读性。 + * + * @author zc + * @since 2021-12-10 13:28 + */ +@Slf4j +public class MyDataPermissionHandler implements DataPermissionHandler { + + private static final String DEPT_TABLE = "sys_dept"; + private static final String DEPT_ID_COLUMN = "id"; + private static final String DEPT_TREE_PATH_COLUMN = "tree_path"; + + /** + * 获取数据权限的sql片段 + * + * @param where 查询条件 + * @param mappedStatementId mapper接口方法的全路径 + * @return sql片段 + */ + @Override + @SneakyThrows + public Expression getSqlSegment(Expression where, String mappedStatementId) { + // 如果是未登录,或者是定时任务执行的SQL,或者是超级管理员,直接返回 + if (SecurityUtils.getUserId() == null || SecurityUtils.isRoot()) { + return where; + } + + // 获取当前用户的数据权限列表 + List dataScopes = SecurityUtils.getUser() + .map(SysUserDetails::getDataScopes) + .orElse(List.of()); + + // 如果任一角色是 ALL,则跳过数据权限过滤(并集策略) + if (hasAllDataScope(dataScopes)) { + return where; + } + + // 如果没有数据权限,跳过过滤 + if (CollectionUtil.isEmpty(dataScopes)) { + return where; + } + + // 获取当前执行的接口类 + Class clazz = Class.forName(mappedStatementId.substring(0, mappedStatementId.lastIndexOf(StringPool.DOT))); + // 获取当前执行的方法名称 + String methodName = mappedStatementId.substring(mappedStatementId.lastIndexOf(StringPool.DOT) + 1); + // 获取当前执行的接口类里所有的方法 + Method[] methods = clazz.getDeclaredMethods(); + for (Method method : methods) { + // 找到当前执行的方法 + if (method.getName().equals(methodName)) { + DataPermission annotation = method.getAnnotation(DataPermission.class); + // 判断当前执行的方法是否有权限注解,如果没有注解直接返回 + if (annotation == null) { + return where; + } + // 使用并集策略过滤 + return dataScopeFilterWithUnion(mappedStatementId, annotation, dataScopes, where); + } + } + return where; + } + + /** + * 判断是否包含"全部数据"权限 + * + * @param dataScopes 数据权限列表 + * @return 是否有全部数据权限 + */ + private boolean hasAllDataScope(List dataScopes) { + if (CollectionUtil.isEmpty(dataScopes)) { + return false; + } + return dataScopes.stream() + .anyMatch(scope -> DataScopeEnum.ALL.getValue().equals(scope.getDataScope())); + } + + /** + * 使用并集策略进行数据权限过滤 + *

+ * 多个角色的数据权限通过 OR 连接,实现并集效果 + * + * @param annotation 数据权限注解 + * @param dataScopes 数据权限列表 + * @param where 原始查询条件 + * @return 追加权限过滤后的查询条件 + */ + @SneakyThrows + private Expression dataScopeFilterWithUnion(String mappedStatementId, DataPermission annotation, List dataScopes, Expression where) { + String deptAlias = annotation.deptAlias(); + String deptIdColumnName = annotation.deptIdColumnName(); + String userAlias = annotation.userAlias(); + String userIdColumnName = annotation.userIdColumnName(); + + // 构建各角色的数据权限条件,使用 OR 连接实现并集 + Expression unionExpression = null; + for (RoleDataScope dataScope : dataScopes) { + Expression roleExpression = buildRoleDataScopeExpression( + deptAlias, deptIdColumnName, userAlias, userIdColumnName, dataScope); + if (roleExpression != null) { + if (unionExpression == null) { + unionExpression = roleExpression; + } else { + // 使用 OR 连接各角色的条件(并集) + unionExpression = new OrExpression(unionExpression, roleExpression); + } + } + } + + if (unionExpression == null) { + return where; + } + + // 用括号包裹并集条件 + Expression finalExpression = CCJSqlParserUtil.parseCondExpression("(" + unionExpression + ")"); + + if (where == null) { + log.debug("DataPermission applied. mappedStatementId={}, segment={}", mappedStatementId, finalExpression); + return finalExpression; + } + + Expression combined = new AndExpression(where, finalExpression); + log.debug("DataPermission applied. mappedStatementId={}, originWhere={}, segment={}, combined={}", + mappedStatementId, where, finalExpression, combined); + return combined; + } + + /** + * 构建单个角色的数据权限SQL条件 + *

+ * 使用 JSQLParser 构建 Expression,避免字符串拼接 + * + * @param deptAlias 部门表别名 + * @param deptIdColumnName 部门ID字段名 + * @param userAlias 用户表别名 + * @param userIdColumnName 用户ID字段名 + * @param roleDataScope 角色数据权限 + * @return 数据权限条件表达式 + */ + private Expression buildRoleDataScopeExpression(String deptAlias, String deptIdColumnName, + String userAlias, String userIdColumnName, + RoleDataScope roleDataScope) { + Column deptColumn = buildColumn(deptAlias, deptIdColumnName); + Column userColumn = buildColumn(userAlias, userIdColumnName); + + Long deptId = SecurityUtils.getDeptId(); + Long userId = SecurityUtils.getUserId(); + + DataScopeEnum dataScopeEnum = DataScopeEnum.getByValue(roleDataScope.getDataScope()); + if (dataScopeEnum == null) { + return null; + } + + return switch (dataScopeEnum) { + case ALL -> null; // 全部数据权限,不添加过滤条件 + case DEPT_AND_SUB -> buildDeptAndSubExpression(deptColumn, deptId); + case DEPT -> buildEqualsExpression(deptColumn, deptId); + case SELF -> buildEqualsExpression(userColumn, userId); + case CUSTOM -> buildCustomDeptExpression(deptColumn, roleDataScope.getCustomDeptIds()); + }; + } + + /** + * 构建列引用 + * + * @param alias 表别名 + * @param columnName 列名 + * @return 列引用 + */ + private Column buildColumn(String alias, String columnName) { + if (StrUtil.isNotBlank(alias)) { + return new Column(alias + StringPool.DOT + columnName); + } + return new Column(columnName); + } + + /** + * 构建等于条件 + * + * @param column 列 + * @param value 值 + * @return 等于表达式 + */ + private Expression buildEqualsExpression(Column column, Long value) { + EqualsTo equalsTo = new EqualsTo(); + equalsTo.setLeftExpression(column); + equalsTo.setRightExpression(new LongValue(value)); + return equalsTo; + } + + /** + * 构建部门及子部门数据权限条件 + *

+ * SQL: dept_id IN (SELECT id FROM sys_dept WHERE id = ? OR FIND_IN_SET(?, tree_path)) + * + * @param deptColumn 部门列 + * @param deptId 部门ID + * @return IN 子查询表达式 + */ + @SneakyThrows + private Expression buildDeptAndSubExpression(Column deptColumn, Long deptId) { + // 使用字符串解析,避免不同 JSqlParser 版本下 InExpression/ItemsList 渲染差异导致 SQL 语法错误 + // SQL: dept_id IN (SELECT id FROM sys_dept WHERE id = ? OR FIND_IN_SET(?, tree_path)) + String columnName = deptColumn.toString(); + String sql = columnName + " IN (SELECT " + DEPT_ID_COLUMN + " FROM " + DEPT_TABLE + + " WHERE " + DEPT_ID_COLUMN + " = " + deptId + + " OR FIND_IN_SET(" + deptId + ", " + DEPT_TREE_PATH_COLUMN + "))"; + return CCJSqlParserUtil.parseCondExpression(sql); + } + + /** + * 构建自定义部门数据权限条件 + *

+ * SQL: dept_id IN (?, ?, ...) + * + * @param deptColumn 部门列 + * @param customDeptIds 自定义部门ID列表 + * @return IN 表达式,如果没有部门则返回 1=0 + */ + @SneakyThrows + private Expression buildCustomDeptExpression(Column deptColumn, List customDeptIds) { + if (CollectionUtil.isEmpty(customDeptIds)) { + // 没有自定义部门,返回 1=0(无权限) + EqualsTo falseCondition = new EqualsTo(); + falseCondition.setLeftExpression(new LongValue(1)); + falseCondition.setRightExpression(new LongValue(0)); + return falseCondition; + } + + // 使用字符串解析,确保渲染始终为 IN (..) + String columnName = deptColumn.toString(); + String ids = customDeptIds.stream().map(String::valueOf).reduce((a, b) -> a + "," + b).orElse(""); + String sql = columnName + " IN (" + ids + ")"; + return CCJSqlParserUtil.parseCondExpression(sql); + } + +} diff --git a/src/main/java/com/youlai/boot/framework/security/config/PasswordEncoderConfig.java b/src/main/java/com/youlai/boot/framework/security/config/PasswordEncoderConfig.java new file mode 100644 index 0000000..6ee0f46 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/config/PasswordEncoderConfig.java @@ -0,0 +1,24 @@ +package com.youlai.boot.framework.security.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +/** + * 密码编码器 + * + * @author Ray.Hao + * @since 2024/12/3 + */ +@Configuration +public class PasswordEncoderConfig { + + /** + * 密码编码器 + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/youlai/boot/framework/security/config/SecurityConfig.java b/src/main/java/com/youlai/boot/framework/security/config/SecurityConfig.java new file mode 100644 index 0000000..190175e --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/config/SecurityConfig.java @@ -0,0 +1,158 @@ +package com.youlai.boot.framework.security.config; + +import cn.binarywang.wx.miniapp.api.WxMaService; +import com.youlai.boot.framework.captcha.service.CaptchaService; +import cn.hutool.core.util.ArrayUtil; +import com.youlai.boot.framework.web.filter.RateLimiterFilter; +import com.youlai.boot.framework.security.filter.CaptchaValidationFilter; +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.token.TokenManager; +import com.youlai.boot.framework.security.service.SysUserDetailsService; +import com.youlai.boot.system.service.ConfigService; +import com.youlai.boot.system.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; +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; + +/** + * Spring Security 配置类 + * + * @author Ray.Hao + * @since 2023/2/17 + */ +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@RequiredArgsConstructor +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 CaptchaService captchaService; + private final ConfigService configService; + 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(); + } + // 其他所有请求需登录后访问 + requestMatcherRegistry.anyRequest().authenticated(); + } + ) + .exceptionHandling(configurer -> + configurer + .authenticationEntryPoint(new MyAuthenticationEntryPoint()) // 未认证异常处理器 + .accessDeniedHandler(new MyAccessDeniedHandler()) // 无权限访问异常处理器 + ) + + // 禁用默认的 Spring Security 特性,适用于前后端分离架构 + .sessionManagement(configurer -> + configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 无状态认证,不使用 Session + ) + .csrf(AbstractHttpConfigurer::disable) // 禁用 CSRF 防护,前后端分离无需此防护机制 + .formLogin(AbstractHttpConfigurer::disable) // 禁用默认的表单登录功能,前后端分离采用 Token 认证方式 + .httpBasic(AbstractHttpConfigurer::disable) // 禁用 HTTP Basic 认证,避免弹窗式登录 + // 禁用 X-Frame-Options 响应头,允许页面被嵌套到 iframe 中 + .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) + // 限流过滤器 + .addFilterBefore(new RateLimiterFilter(redisTemplate, configService), UsernamePasswordAuthenticationFilter.class) + // 验证码校验过滤器 + .addFilterBefore(new CaptchaValidationFilter(captchaService), UsernamePasswordAuthenticationFilter.class) + // 验证和解析过滤器 + .addFilterBefore(new TokenAuthenticationFilter(tokenManager), UsernamePasswordAuthenticationFilter.class) + .build(); + } + + /** + * 配置Web安全自定义器,以忽略特定请求路径的安全性检查。 + *

+ * 该配置用于指定哪些请求路径不经过Spring Security过滤器链。通常用于静态资源文件。 + */ + @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + return (web) -> { + String[] unsecuredUrls = securityProperties.getUnsecuredUrls(); + if (ArrayUtil.isNotEmpty(unsecuredUrls)) { + web.ignoring().requestMatchers(unsecuredUrls); + } + }; + } + + /** + * 默认密码认证的 Provider + */ + @Bean + public DaoAuthenticationProvider daoAuthenticationProvider() { + DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider(userDetailsService); + daoAuthenticationProvider.setPasswordEncoder(passwordEncoder); + return daoAuthenticationProvider; + } + + /** + * 短信验证码认证 Provider + */ + @Bean + public SmsAuthenticationProvider smsAuthenticationProvider() { + return new SmsAuthenticationProvider(userService, redisTemplate); + } + + /** + * 微信小程序认证 Provider + */ + @Bean + public WxMaAuthenticationProvider wechatMiniAuthenticationProvider( + WxMaService wxMaService, + SysUserDetailsService sysUserDetailsService + ) { + return new WxMaAuthenticationProvider(wxMaService, sysUserDetailsService); + } + + /** + * 认证管理器 + */ + @Bean + public AuthenticationManager authenticationManager( + DaoAuthenticationProvider daoAuthenticationProvider, + SmsAuthenticationProvider smsAuthenticationProvider, + WxMaAuthenticationProvider wxMaAuthenticationProvider + ) { + return new ProviderManager( + daoAuthenticationProvider, + smsAuthenticationProvider, + wxMaAuthenticationProvider + ); + } + +} diff --git a/src/main/java/com/youlai/boot/framework/security/config/SecurityProperties.java b/src/main/java/com/youlai/boot/framework/security/config/SecurityProperties.java new file mode 100644 index 0000000..ce5d460 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/config/SecurityProperties.java @@ -0,0 +1,114 @@ +package com.youlai.boot.framework.security.config; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +/** + * 安全模块配置属性类 + * + *

映射 application.yml 中 security 前缀的安全相关配置

+ * + * @author Ray.Hao + * @since 2024/4/18 + */ +@Data +@Component +@Validated +@ConfigurationProperties(prefix = "security") +public class SecurityProperties { + + /** + * 会话管理配置 + */ + private SessionConfig session; + + /** + * 安全白名单路径(完全绕过安全过滤器) + *

示例值:/api/v1/auth/login/**, /ws/** + */ + @NotEmpty + private String[] ignoreUrls; + + /** + * 非安全端点路径(允许匿名访问的API) + *

示例值:/doc.html, /v3/api-docs/** + */ + @NotEmpty + private String[] unsecuredUrls; + + /** + * 会话配置嵌套类 + */ + @Data + public static class SessionConfig { + /** + * 认证策略类型 + *

    + *
  • jwt - 基于JWT的无状态认证
  • + *
  • redis-token - 基于Redis的有状态认证
  • + *
+ */ + @NotNull(message = "会话类型不能为空") + @Pattern(regexp = "jwt|redis-token", message = "会话类型只能是 jwt 或 redis-token") + private String type; + + /** + * 访问令牌有效期(单位:秒) + *

默认值:3600(1小时)

+ *

-1 表示永不过期

+ */ + @Min(-1) + private Integer accessTokenTimeToLive = 3600; + + /** + * 刷新令牌有效期(单位:秒) + *

默认值:604800(7天)

+ *

-1 表示永不过期

+ */ + @Min(-1) + private Integer refreshTokenTimeToLive = 604800; + + /** + * JWT 配置项 + */ + private JwtConfig jwt; + + /** + * Redis令牌配置项 + */ + private RedisTokenConfig redisToken; + } + + /** + * JWT 配置嵌套类 + */ + @Data + public static class JwtConfig { + /** + * JWT签名密钥 + *

HS256算法要求至少32个字符

+ *

示例:SecretKey012345678901234567890123456789

+ */ + @NotNull + private String secretKey; + } + + /** + * Redis令牌配置嵌套类 + */ + @Data + public static class RedisTokenConfig { + /** + * 是否允许多设备同时登录 + *

true - 允许同一账户多设备登录(默认)

+ *

false - 新登录会使旧令牌失效

+ */ + private Boolean allowMultiLogin = true; + } +} diff --git a/src/main/java/com/youlai/boot/framework/security/exception/CaptchaValidationException.java b/src/main/java/com/youlai/boot/framework/security/exception/CaptchaValidationException.java new file mode 100644 index 0000000..b7c68cc --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/exception/CaptchaValidationException.java @@ -0,0 +1,15 @@ +package com.youlai.boot.framework.security.exception; + +import org.springframework.security.core.AuthenticationException; + +/** + * 验证码校验异常 + * + * @author Ray.Hao + * @since 2025/3/1 + */ +public class CaptchaValidationException extends AuthenticationException { + public CaptchaValidationException(String msg) { + super(msg); + } +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/framework/security/exception/NeedBindMobileException.java b/src/main/java/com/youlai/boot/framework/security/exception/NeedBindMobileException.java new file mode 100644 index 0000000..706b617 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/exception/NeedBindMobileException.java @@ -0,0 +1,28 @@ +package com.youlai.boot.framework.security.exception; + +import org.springframework.security.core.AuthenticationException; + +/** + * 需要绑定手机号异常 + */ +public class NeedBindMobileException extends AuthenticationException { + + private final String openid; + + private final String sessionKey; + + public NeedBindMobileException(String openid, String sessionKey) { + super("需要绑定手机号"); + this.openid = openid; + this.sessionKey = sessionKey; + } + + public String getOpenid() { + return openid; + } + + public String getSessionKey() { + return sessionKey; + } + +} diff --git a/src/main/java/com/youlai/boot/framework/security/filter/CaptchaValidationFilter.java b/src/main/java/com/youlai/boot/framework/security/filter/CaptchaValidationFilter.java new file mode 100644 index 0000000..4f1d6fc --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/filter/CaptchaValidationFilter.java @@ -0,0 +1,143 @@ +package com.youlai.boot.framework.security.filter; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import com.youlai.boot.common.constant.SecurityConstants; +import com.youlai.boot.common.result.ResultCode; +import com.youlai.boot.common.result.ResponseWriter; +import com.youlai.boot.framework.captcha.exception.CaptchaException; +import com.youlai.boot.framework.captcha.service.CaptchaService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.StreamUtils; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingRequestWrapper; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +/** + * 图形验证码校验过滤器 + */ +public class CaptchaValidationFilter extends OncePerRequestFilter { + + private static final RequestMatcher LOGIN_PATH_REQUEST_MATCHER = PathPatternRequestMatcher.withDefaults() + .matcher(HttpMethod.POST, SecurityConstants.LOGIN_PATH); + + public static final String CAPTCHA_CODE_PARAM_NAME = "captchaCode"; + public static final String CAPTCHA_ID_PARAM_NAME = "captchaId"; + + private final CaptchaService captchaService; + + public CaptchaValidationFilter(CaptchaService captchaService) { + this.captchaService = captchaService; + } + + @Override + 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()); + String body = new String(bodyBytes, StandardCharsets.UTF_8); + String captchaCode = null; + String captchaId = null; + + if (StrUtil.isNotBlank(body)) { + JSONObject jsonObject = JSONUtil.parseObj(body); + captchaCode = jsonObject.getStr(CAPTCHA_CODE_PARAM_NAME); + captchaId = jsonObject.getStr(CAPTCHA_ID_PARAM_NAME); + } + + try { + captchaService.validate(captchaId, captchaCode); + HttpServletRequest repeatableRequest = new RepeatableReadRequestWrapper(requestWrapper, bodyBytes); + chain.doFilter(repeatableRequest, response); + } catch (CaptchaException e) { + ResponseWriter.writeError(response, e.getResultCode()); + } + } + + /** + * 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; + + RepeatableReadRequestWrapper(HttpServletRequest request, byte[] cachedBody) { + super(request); + this.cachedBody = cachedBody != null ? cachedBody : new byte[0]; + } + + @Override + public ServletInputStream getInputStream() { + ByteArrayInputStream bais = new ByteArrayInputStream(cachedBody); + return new ServletInputStream() { + @Override + public int read() { + return bais.read(); + } + + @Override + public boolean isFinished() { + return bais.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(jakarta.servlet.ReadListener readListener) { + // no-op + } + }; + } + + @Override + public BufferedReader getReader() { + return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); + } + + @Override + public int getContentLength() { + return cachedBody.length; + } + + @Override + public long getContentLengthLong() { + return cachedBody.length; + } + } +} + + 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 new file mode 100644 index 0000000..d2514c1 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/filter/TokenAuthenticationFilter.java @@ -0,0 +1,80 @@ +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.common.result.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.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * Token 认证校验过滤器 + * + * @author wangtao + * @since 2025/3/6 16:50 + */ +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 { + + String rawToken = resolveToken(request); + + try { + if (StrUtil.isNotBlank(rawToken)) { + // 执行令牌有效性检查(包含密码学验签和过期时间验证) + boolean isValidToken = tokenManager.validateToken(rawToken); + if (!isValidToken) { + ResponseWriter.writeError(response, ResultCode.ACCESS_TOKEN_INVALID); + return; + } + + // 将令牌解析为 Spring Security 上下文认证对象 + Authentication authentication = tokenManager.parseToken(rawToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } 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()); + } + return null; + } +} diff --git a/src/main/java/com/youlai/boot/framework/security/handler/MyAccessDeniedHandler.java b/src/main/java/com/youlai/boot/framework/security/handler/MyAccessDeniedHandler.java new file mode 100644 index 0000000..712cc0a --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/handler/MyAccessDeniedHandler.java @@ -0,0 +1,25 @@ +package com.youlai.boot.framework.security.handler; + +import com.youlai.boot.common.result.ResultCode; +import com.youlai.boot.common.result.ResponseWriter; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * 无权限访问处理器 + * + * @author Ray.Hao + * @since 2.0.0 + */ +public class MyAccessDeniedHandler implements AccessDeniedHandler { + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) { + // 权限不足返回 403 Forbidden + 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/framework/security/handler/MyAuthenticationEntryPoint.java new file mode 100644 index 0000000..b511014 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/handler/MyAuthenticationEntryPoint.java @@ -0,0 +1,48 @@ +package com.youlai.boot.framework.security.handler; + +import com.youlai.boot.common.result.ResultCode; +import com.youlai.boot.common.result.ResponseWriter; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; + +/** + * 统一处理 Spring Security 认证失败响应 + * + * @author Ray.Hao + * @since 2.0.0 + */ +public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint { + + /** + * 认证失败处理入口方法 + * + * @param request 触发异常的请求对象(可用于获取请求头、参数等) + * @param response 响应对象(用于写入错误信息) + * @param authException 认证异常对象(包含具体失败原因) + */ + @Override + 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过期、签名验证失败 + 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/AuthenticationToken.java b/src/main/java/com/youlai/boot/framework/security/model/AuthenticationToken.java new file mode 100644 index 0000000..cd0dac5 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/model/AuthenticationToken.java @@ -0,0 +1,30 @@ +package com.youlai.boot.framework.security.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +/** + * 认证令牌响应对象 + * + * @author Ray.Hao + * @since 0.0.1 + */ +@Schema(description = "认证令牌响应对象") +@Data +@Builder +public class AuthenticationToken { + + @Schema(description = "令牌类型", example = "Bearer") + private String tokenType; + + @Schema(description = "访问令牌") + private String accessToken; + + @Schema(description = "刷新令牌") + private String refreshToken; + + @Schema(description = "过期时间(单位:秒)") + private Integer expiresIn; + +} diff --git a/src/main/java/com/youlai/boot/framework/security/model/RoleDataScope.java b/src/main/java/com/youlai/boot/framework/security/model/RoleDataScope.java new file mode 100644 index 0000000..2fbcb6b --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/model/RoleDataScope.java @@ -0,0 +1,76 @@ +package com.youlai.boot.framework.security.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.List; + +/** + * 角色数据权限信息 + *

+ * 用于存储单个角色的数据权限范围信息,支持多角色数据权限合并(并集策略) + * + * @author Ray.Hao + * @since 3.0.0 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class RoleDataScope implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 角色编码 + */ + private String roleCode; + + /** + * 数据权限范围值 + * 1-所有数据 2-部门及子部门数据 3-本部门数据 4-本人数据 5-自定义部门数据 + */ + private Integer dataScope; + + /** + * 自定义部门ID列表(仅当 dataScope=5 时有效) + */ + private List customDeptIds; + + /** + * 创建"全部数据"权限 + */ + public static RoleDataScope all(String roleCode) { + return new RoleDataScope(roleCode, 1, null); + } + + /** + * 创建"部门及子部门"权限 + */ + public static RoleDataScope deptAndSub(String roleCode) { + return new RoleDataScope(roleCode, 2, null); + } + + /** + * 创建"本部门"权限 + */ + public static RoleDataScope dept(String roleCode) { + return new RoleDataScope(roleCode, 3, null); + } + + /** + * 创建"本人"权限 + */ + public static RoleDataScope self(String roleCode) { + return new RoleDataScope(roleCode, 4, null); + } + + /** + * 创建"自定义部门"权限 + */ + public static RoleDataScope custom(String roleCode, List deptIds) { + return new RoleDataScope(roleCode, 5, deptIds); + } + +} diff --git a/src/main/java/com/youlai/boot/framework/security/model/SmsAuthenticationToken.java b/src/main/java/com/youlai/boot/framework/security/model/SmsAuthenticationToken.java new file mode 100644 index 0000000..bdcb60f --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/model/SmsAuthenticationToken.java @@ -0,0 +1,91 @@ +package com.youlai.boot.framework.security.model; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; + +import java.io.Serial; +import java.util.Collection; + +/** + * 短信验证码认证 Token + *

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

    + *
  • 未认证状态:principal 为手机号,credentials 为验证码
  • + *
  • 已认证状态:principal 为用户详情,credentials 为 null
  • + *
+ * + * @author Ray.Hao + * @since 2.20.0 + */ +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; + this.credentials = verifyCode; + setAuthenticated(false); + } + + /** + * 创建已认证的 Token + * + * @param principal 用户详情(SysUserDetails) + * @param authorities 授权信息 + */ + public SmsAuthenticationToken(Object principal, Collection authorities) { + super(authorities); + this.principal = principal; + this.credentials = null; + super.setAuthenticated(true); + } + + /** + * 创建已认证的 Token(静态工厂方法) + * + * @param principal 用户详情(SysUserDetails) + * @param authorities 授权信息 + * @return 已认证的 SmsAuthenticationToken + */ + public static SmsAuthenticationToken authenticated(Object principal, Collection authorities) { + return new SmsAuthenticationToken(principal, authorities); + } + + @Override + public Object getCredentials() { + return this.credentials; + } + + @Override + public Object getPrincipal() { + return this.principal; + } +} diff --git a/src/main/java/com/youlai/boot/framework/security/model/SysUserDetails.java b/src/main/java/com/youlai/boot/framework/security/model/SysUserDetails.java new file mode 100644 index 0000000..2e733b1 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/model/SysUserDetails.java @@ -0,0 +1,129 @@ +package com.youlai.boot.framework.security.model; + +import cn.hutool.core.collection.CollectionUtil; +import cn.hutool.core.util.ObjectUtil; +import com.youlai.boot.common.constant.SecurityConstants; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * Spring Security 用户认证对象 + *

+ * 封装了用户的基本信息和权限信息,供 Spring Security 进行用户认证与授权。 + * 实现了 {@link UserDetails} 接口,提供用户的核心信息。 + * + * @author Ray.Hao + * @version 3.0.0 + */ +@Data +@NoArgsConstructor +public class SysUserDetails implements UserDetails { + + /** + * 用户ID + */ + private Long userId; + + /** + * 用户名 + */ + private String username; + + /** + * 密码 + */ + private String password; + + /** + * 账号是否启用(true:启用 false:禁用) + */ + private Boolean enabled; + + /** + * 部门ID + */ + private Long deptId; + + /** + * 数据权限列表 + *

+ * 存储用户所有角色的数据权限范围,用于实现多角色权限合并(并集策略) + */ + private List dataScopes; + + /** + * 用户角色权限集合 + */ + private Collection authorities; + + /** + * 构造函数:根据用户认证信息初始化用户详情对象 + * + * @param user 用户认证信息对象 {@link UserAuthInfo} + */ + public SysUserDetails(UserAuthInfo 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(); + } + + + @Override + public Collection getAuthorities() { + return this.authorities; + } + + @Override + public String getPassword() { + return this.password; + } + + @Override + public String getUsername() { + return this.username; + } + + @Override + public boolean isEnabled() { + return this.enabled; + } + + /** + * 判断是否包含"全部数据"权限 + * + * @return 是否有全部数据权限 + */ + public boolean hasAllDataScope() { + if (CollectionUtil.isEmpty(dataScopes)) { + return false; + } + return dataScopes.stream() + .anyMatch(scope -> scope.getDataScope() == 1); + } + + /** + * 获取数据权限列表 + * + * @return 数据权限列表,永不为null + */ + public List getDataScopes() { + return dataScopes != null ? dataScopes : Collections.emptyList(); + } +} diff --git a/src/main/java/com/youlai/boot/framework/security/model/UserAuthInfo.java b/src/main/java/com/youlai/boot/framework/security/model/UserAuthInfo.java new file mode 100644 index 0000000..e6b17d2 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/model/UserAuthInfo.java @@ -0,0 +1,61 @@ +package com.youlai.boot.framework.security.model; + +import lombok.Data; + +import java.util.List; +import java.util.Set; + +/** + * 用户认证信息 + *

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

+ * + * @author Ray.Hao + * @since 2025/12/16 + */ +@Data +public class UserAuthInfo { + + /** + * 用户ID + */ + private Long userId; + + /** + * 用户名 + */ + private String username; + + /** + * 昵称 + */ + private String nickname; + + /** + * 部门ID + */ + private Long deptId; + + /** + * 密码(加密后) + */ + private String password; + + /** + * 状态(1:启用 其它:禁用) + */ + private Integer status; + + /** + * 角色集合 + */ + private Set roles; + + /** + * 数据权限列表 + *

+ * 存储用户所有角色的数据权限范围,用于实现多角色权限合并(并集策略) + */ + private List dataScopes; +} 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 new file mode 100644 index 0000000..a798a4c --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/model/UserSession.java @@ -0,0 +1,49 @@ +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/WxMaAuthenticationToken.java b/src/main/java/com/youlai/boot/framework/security/model/WxMaAuthenticationToken.java new file mode 100644 index 0000000..584929e --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/model/WxMaAuthenticationToken.java @@ -0,0 +1,76 @@ +package com.youlai.boot.framework.security.model; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; + +import java.io.Serial; +import java.util.Collection; + +/** + * 微信小程序认证 Token + * + * @author Ray.Hao + * @since 4.0.0 + */ +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; + this.credentials = null; + setAuthenticated(false); + } + + /** + * 创建已认证的 Token + * + * @param principal 用户详情(SysUserDetails) + * @param authorities 授权信息 + */ + public WxMaAuthenticationToken(Object principal, Collection authorities) { + super(authorities); + this.principal = principal; + this.credentials = null; + super.setAuthenticated(true); + } + + /** + * 创建已认证的 Token(静态工厂方法) + */ + public static WxMaAuthenticationToken authenticated(Object principal, Collection authorities) { + return new WxMaAuthenticationToken(principal, authorities); + } + + @Override + public Object getCredentials() { + return this.credentials; + } + + @Override + public Object getPrincipal() { + return this.principal; + } +} diff --git a/src/main/java/com/youlai/boot/framework/security/provider/SmsAuthenticationProvider.java b/src/main/java/com/youlai/boot/framework/security/provider/SmsAuthenticationProvider.java new file mode 100644 index 0000000..ec5c63f --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/provider/SmsAuthenticationProvider.java @@ -0,0 +1,122 @@ +package com.youlai.boot.framework.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.CaptchaValidationException; +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 lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +/** + * 短信验证码认证 Provider + *

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

+ * 认证流程: + *

    + *
  1. 根据手机号查询用户信息
  2. + *
  3. 校验用户状态(是否禁用)
  4. + *
  5. 校验短信验证码(与 Redis 缓存比对)
  6. + *
  7. 验证成功后删除验证码,防止重复使用
  8. + *
  9. 返回已认证的 Authentication
  10. + *
+ * + * @author Ray.Hao + * @since 2.17.0 + * @see SmsAuthenticationToken + * @see AuthenticationProvider + */ +@Slf4j +public class SmsAuthenticationProvider implements AuthenticationProvider { + + private final UserService userService; + + private final RedisTemplate redisTemplate; + + public SmsAuthenticationProvider(UserService userService, RedisTemplate redisTemplate) { + this.userService = userService; + 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 CaptchaValidationException("手机号不能为空"); + } + if (StrUtil.isBlank(inputVerifyCode)) { + log.warn("短信验证码登录失败:验证码为空,手机号={}", mobile); + throw new CaptchaValidationException("验证码不能为空"); + } + + // 根据手机号获取用户信息 + UserAuthInfo userAuthInfo = userService.getAuthInfoByMobile(mobile); + + if (userAuthInfo == null) { + log.warn("短信验证码登录失败:用户不存在,手机号={}", mobile); + throw new UsernameNotFoundException("用户不存在"); + } + + // 检查用户状态是否有效 + if (ObjectUtil.notEqual(userAuthInfo.getStatus(), 1)) { + log.warn("短信验证码登录失败:用户已禁用,用户名={}", userAuthInfo.getUsername()); + throw new DisabledException("用户已被禁用"); + } + + // 校验短信验证码 + String cacheKey = StrUtil.format(RedisConstants.Captcha.SMS_LOGIN_CODE, mobile); + String cachedVerifyCode = (String) redisTemplate.opsForValue().get(cacheKey); + + if (cachedVerifyCode == null) { + log.warn("短信验证码登录失败:验证码已过期,手机号={}", mobile); + throw new CaptchaValidationException("验证码已过期,请重新获取"); + } + + if (!StrUtil.equals(inputVerifyCode, cachedVerifyCode)) { + log.warn("短信验证码登录失败:验证码错误,手机号={}", mobile); + throw new CaptchaValidationException("验证码错误"); + } + + // 验证成功后删除验证码,防止重复使用 + redisTemplate.delete(cacheKey); + + // 构建认证后的用户详情信息 + SysUserDetails userDetails = new SysUserDetails(userAuthInfo); + + log.info("短信验证码登录成功:用户名={},手机号={}", userAuthInfo.getUsername(), mobile); + + // 创建已认证的 SmsAuthenticationToken + 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/framework/security/provider/WxMaAuthenticationProvider.java new file mode 100644 index 0000000..d30bfd6 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/provider/WxMaAuthenticationProvider.java @@ -0,0 +1,92 @@ +package com.youlai.boot.framework.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.system.model.entity.UserSocial; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import me.chanjar.weixin.common.error.WxErrorException; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +/** + * 微信小程序认证 Provider + */ +@Slf4j +@RequiredArgsConstructor +public class WxMaAuthenticationProvider implements AuthenticationProvider { + + private final WxMaService wxMaService; + private final SysUserDetailsService sysUserDetailsService; + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + String code = (String) authentication.getPrincipal(); + + if (code == null || code.isEmpty()) { + log.warn("微信小程序登录失败:code为空"); + throw new IllegalArgumentException("code不能为空"); + } + + 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); + + if (userSocial == null) { + // 未绑定,抛出异常提示需要绑定手机号 + log.info("微信小程序登录:用户未绑定手机号,openid={}", openid); + throw new NeedBindMobileException(openid, sessionKey); + } + + // 3. 获取用户认证信息 + UserAuthInfo userAuthInfo = sysUserDetailsService.getAuthInfoByWechatOpenid(openid); + + if (userAuthInfo == null) { + log.warn("微信小程序登录失败:用户不存在,openid={}", openid); + throw new UsernameNotFoundException("用户不存在"); + } + + // 4. 检查用户状态 + if (ObjectUtil.notEqual(userAuthInfo.getStatus(), 1)) { + log.warn("微信小程序登录失败:用户已禁用,username={}", userAuthInfo.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); + + return WxMaAuthenticationToken.authenticated(userDetails, userDetails.getAuthorities()); + + } catch (WxErrorException e) { + log.error("微信小程序登录失败:调用微信接口异常,code={}", code, e); + throw new IllegalArgumentException("微信登录失败:" + e.getMessage()); + } + } + + @Override + public boolean supports(Class authentication) { + return WxMaAuthenticationToken.class.isAssignableFrom(authentication); + } + +} 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 new file mode 100644 index 0000000..98bf1c9 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/service/PermissionService.java @@ -0,0 +1,72 @@ +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.util.SecurityUtils; +import com.youlai.boot.system.service.RoleMenuService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.util.PatternMatchUtils; + +import java.util.Set; + +/** + * Spring Security 权限校验组件 + *

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

+ * 权限数据来源:{@link RoleMenuService#getRolePermsByRoleCodes}(带 Redis 缓存) + * + * @author Ray.Hao + * @since 0.0.1 + */ +@Component("ss") +@RequiredArgsConstructor +@Slf4j +public class PermissionService { + + private final RoleMenuService roleMenuService; + + /** + * 判断当前登录用户是否拥有操作权限 + *

+ * 支持通配符匹配,如:权限码 "sys:user:*" 可匹配 "sys:user:add"、"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); + if (CollectionUtil.isEmpty(rolePerms)) { + return false; + } + + // 判断权限列表中是否包含所需权限(支持通配符) + boolean hasPermission = rolePerms.stream() + .anyMatch(rolePerm -> PatternMatchUtils.simpleMatch(rolePerm, requiredPerm)); + + if (!hasPermission) { + log.warn("用户无操作权限:userId={}, username={}, requiredPerm={}", + SecurityUtils.getUserId(), SecurityUtils.getUsername(), requiredPerm); + } + return hasPermission; + } + +} 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 new file mode 100644 index 0000000..2bbe035 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/service/SysUserDetailsService.java @@ -0,0 +1,82 @@ +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/token/JwtTokenManager.java b/src/main/java/com/youlai/boot/framework/security/token/JwtTokenManager.java new file mode 100644 index 0000000..10966bd --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/token/JwtTokenManager.java @@ -0,0 +1,403 @@ +package com.youlai.boot.framework.security.token; + +import cn.hutool.core.convert.Convert; +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONArray; +import cn.hutool.json.JSONObject; +import cn.hutool.jwt.JWT; +import cn.hutool.jwt.JWTPayload; +import cn.hutool.jwt.JWTUtil; +import com.youlai.boot.common.constant.JwtClaimConstants; +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.framework.security.config.SecurityProperties; +import com.youlai.boot.framework.security.model.AuthenticationToken; +import com.youlai.boot.framework.security.model.RoleDataScope; +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.stream.Collectors; + +/** + * JWT Token 管理器 + *

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

    + *
  • Access Token + Refresh Token 双令牌机制
  • + *
  • Token 撤销(jti黑名单)
  • + *
  • 用户级会话失效(tokenVersion)
  • + *
  • 多角色数据权限存储
  • + *
+ * + * @author Ray.Hao + * @since 2024/11/15 + */ +@ConditionalOnProperty(value = "security.session.type", havingValue = "jwt") +@Service +public class JwtTokenManager implements TokenManager { + + private final SecurityProperties securityProperties; + private final RedisTemplate redisTemplate; + private final byte[] secretKey; + + public JwtTokenManager(SecurityProperties securityProperties, RedisTemplate redisTemplate) { + this.securityProperties = securityProperties; + this.redisTemplate = redisTemplate; + this.secretKey = securityProperties.getSession().getJwt().getSecretKey().getBytes(); + } + + /** + * 生成令牌 + * + * @param authentication 认证信息 + * @return 令牌响应对象 + */ + @Override + public AuthenticationToken generateToken(Authentication authentication) { + int accessTokenTimeToLive = securityProperties.getSession().getAccessTokenTimeToLive(); + int refreshTokenTimeToLive = securityProperties.getSession().getRefreshTokenTimeToLive(); + + String accessToken = generateToken(authentication, accessTokenTimeToLive); + String refreshToken = generateToken(authentication, refreshTokenTimeToLive, true); + + return AuthenticationToken.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .tokenType("Bearer") + .expiresIn(accessTokenTimeToLive) + .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 + + // 解析数据权限列表 + JSONArray dataScopesArray = payloads.getJSONArray(JwtClaimConstants.DATA_SCOPES); + if (dataScopesArray != null && !dataScopesArray.isEmpty()) { + List dataScopes = dataScopesArray.stream() + .map(obj -> { + JSONObject item = (JSONObject) obj; + String roleCode = item.getStr("roleCode"); + Integer dataScope = item.getInt("dataScope"); + JSONArray deptIdsArray = item.getJSONArray("customDeptIds"); + List customDeptIds = null; + if (deptIdsArray != null) { + customDeptIds = deptIdsArray.toList(Long.class); + } + return new RoleDataScope(roleCode, dataScope, customDeptIds); + }) + .collect(Collectors.toList()); + 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()); + + return new UsernamePasswordAuthenticationToken(userDetails, "", authorities); + } + + /** + * 校验令牌 + * + * @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 因版本号不匹配而失效 + 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 等 + if (isTokenRevoked(jti)) { + return false; + } + } + 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()); + } + JWT jwt = JWTUtil.parseToken(token); + JSONObject payloads = jwt.getPayloads(); + String jti = payloads.getStr(JWTPayload.JWT_ID); + Integer expirationAt = payloads.getInt(JWTPayload.EXPIRES_AT); + 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); + if (!isValid) { + throw new BusinessException(ResultCode.REFRESH_TOKEN_INVALID); + } + Authentication authentication = parseToken(refreshToken); + int accessTokenExpiration = securityProperties.getSession().getAccessTokenTimeToLive(); + String newAccessToken = generateToken(authentication, accessTokenExpiration); + return AuthenticationToken.builder() + .accessToken(newAccessToken) + .refreshToken(refreshToken) + .tokenType("Bearer") + .expiresIn(accessTokenExpiration) + .build(); + } + + /** + * 生成 JWT Token + * + * @param authentication 认证信息 + * @param ttl 过期时间(秒),-1表示永不过期 + * @return JWT Token字符串 + */ + 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(); + Map payload = new HashMap<>(); + payload.put(JwtClaimConstants.USER_ID, userDetails.getUserId()); // 用户ID + payload.put(JwtClaimConstants.DEPT_ID, userDetails.getDeptId()); // 部门ID + + // 存储数据权限列表 + List dataScopes = userDetails.getDataScopes(); + if (dataScopes != null && !dataScopes.isEmpty()) { + List> scopesList = dataScopes.stream() + .map(scope -> { + Map scopeMap = new HashMap<>(); + scopeMap.put("roleCode", scope.getRoleCode()); + scopeMap.put("dataScope", scope.getDataScope()); + scopeMap.put("customDeptIds", scope.getCustomDeptIds()); + return scopeMap; + }) + .collect(Collectors.toList()); + payload.put(JwtClaimConstants.DATA_SCOPES, scopesList); + } + + // claims 中添加角色信息 + Set roles = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toSet()); + payload.put(JwtClaimConstants.AUTHORITIES, roles); + + // 获取当前用户的 Token 版本号,用于会话失效控制 + Long userId = userDetails.getUserId(); + int tokenVersion = 0; + if (userId != null) { + String versionKey = StrUtil.format(RedisConstants.Auth.USER_TOKEN_VERSION, userId); + Object versionObj = redisTemplate.opsForValue().get(versionKey); + tokenVersion = versionObj != null ? Convert.toInt(versionObj) : 0; + } + payload.put(JwtClaimConstants.TOKEN_VERSION, tokenVersion); + + Date now = new Date(); + payload.put(JWTPayload.ISSUED_AT, now); + payload.put(JwtClaimConstants.TOKEN_TYPE, false); + if (isRefreshToken) { + payload.put(JwtClaimConstants.TOKEN_TYPE, true); + } + + // 设置过期时间 -1 表示永不过期 + if (ttl != -1) { + Date expiresAt = DateUtil.offsetSecond(now, ttl); + payload.put(JWTPayload.EXPIRES_AT, expiresAt); + } + payload.put(JWTPayload.SUBJECT, authentication.getName()); + payload.put(JWTPayload.JWT_ID, IdUtil.simpleUUID()); + + return JWTUtil.createToken(payload, secretKey); + } + +} 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 new file mode 100644 index 0000000..f054243 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/token/RedisTokenManager.java @@ -0,0 +1,334 @@ +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; +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.framework.security.config.SecurityProperties; +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 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.Optional; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * Redis Token 管理器 + *

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

    + *
  • Access Token + Refresh Token 双令牌机制
  • + *
  • 单设备/多设备登录控制
  • + *
  • 用户级会话失效
  • + *
  • 在线用户管理
  • + *
+ *

+ * 与JWT模式相比,Redis模式支持主动踢人、在线用户查询等功能 + * + * @author Ray.Hao + * @since 2024/11/15 + */ +@ConditionalOnProperty(value = "security.session.type", havingValue = "redis-token") +@Service +public class RedisTokenManager implements TokenManager { + + private final SecurityProperties securityProperties; + private final RedisTemplate redisTemplate; + + public RedisTokenManager(SecurityProperties securityProperties, RedisTemplate redisTemplate) { + this.securityProperties = securityProperties; + this.redisTemplate = redisTemplate; + } + + /** + * 生成 Token + * + * @param authentication 用户认证信息 + * @return 生成的 AuthenticationToken 对象 + */ + @Override + public AuthenticationToken generateToken(Authentication authentication) { + SysUserDetails user = (SysUserDetails) 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()) + ); + + // 存储访问令牌、刷新令牌和刷新令牌映射 + storeTokensInRedis(accessToken, refreshToken, userSession); + + // 单设备登录控制 + handleSingleDeviceLogin(user.getUserId(), accessToken); + + return AuthenticationToken.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .expiresIn(securityProperties.getSession().getAccessTokenTimeToLive()) + .build(); + } + + /** + * 根据 token 解析用户信息 + * + * @param token Redis Token + * @return 构建的 Authentication 对象 + */ + @Override + public Authentication parseToken(String token) { + UserSession userSession = (UserSession) redisTemplate.opsForValue().get(formatTokenKey(token)); + if (userSession == null) return null; + + // 构建用户权限集合 + 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); + } + + /** + * 校验 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) { + UserSession userSession = (UserSession) redisTemplate.opsForValue() + .get(StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken)); + if (userSession == null) { + throw new BusinessException(ResultCode.REFRESH_TOKEN_INVALID); + } + Object oldAccessTokenValue = redisTemplate.opsForValue().get(StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userSession.getUserId())); + // 删除旧的访问令牌记录 + Optional.of(oldAccessTokenValue) + .map(String.class::cast) + .ifPresent(oldAccessToken -> redisTemplate.delete(formatTokenKey(oldAccessToken))); + + // 生成新访问令牌并存储 + String newAccessToken = IdUtil.fastSimpleUUID(); + storeAccessToken(newAccessToken, userSession); + + int accessTtl = securityProperties.getSession().getAccessTokenTimeToLive(); + return AuthenticationToken.builder() + .accessToken(newAccessToken) + .refreshToken(refreshToken) + .expiresIn(accessTtl) + .build(); + } + + /** + * 使访问令牌失效 + * + * @param token 访问令牌 + */ + @Override + public void invalidateToken(String token) { + String cleanToken = cleanBearerPrefix(token); + Object value = redisTemplate.opsForValue().get(formatTokenKey(cleanToken)); + if (value instanceof UserSession userSession) { + Long userId = userSession.getUserId(); + invalidateUserSessions(userId); + } + } + + /** + * 使指定用户的所有会话失效 + *

+ * 适用场景:用户修改密码、管理员强制下线、账号封禁等 + * + * @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()); + + // 刷新令牌 -> 用户信息 + 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()); + } + + /** + * 处理单设备登录控制 + *

+ * 当配置不允许多设备登录时,新登录会使旧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()); + 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时永不过期 + } + } + + + /** + * 清理 Bearer 前缀 + */ + private String cleanBearerPrefix(String token) { + if (token.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) { + return token.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length()).trim(); + } + return token.trim(); + } +} diff --git a/src/main/java/com/youlai/boot/framework/security/token/TokenManager.java b/src/main/java/com/youlai/boot/framework/security/token/TokenManager.java new file mode 100644 index 0000000..7aef032 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/token/TokenManager.java @@ -0,0 +1,76 @@ +package com.youlai.boot.framework.security.token; + + +import com.youlai.boot.framework.security.model.AuthenticationToken; +import org.springframework.security.core.Authentication; + +/** + * Token 管理器 + *

+ * 用于生成、解析、校验、刷新 Token + * + * @author Ray.Hao + * @since 2.16.0 + */ +public interface TokenManager { + + /** + * 生成认证 Token + * + * @param authentication 用户认证信息 + * @return 认证 Token 响应 + */ + AuthenticationToken generateToken(Authentication authentication); + + /** + * 解析 Token 获取认证信息 + * + * @param token Token + * @return 用户认证信息 + */ + Authentication parseToken(String token); + + /** + * 校验 Token 是否有效 + * + * @param token JWT Token + * @return 是否有效 + */ + boolean validateToken(String token); + + /** + * 校验 刷新 Token 是否有效 + * + * @param refreshToken JWT Token + * @return 是否有效 + */ + boolean validateRefreshToken(String refreshToken); + + /** + * 刷新 Token + * + * @param token 刷新令牌 + * @return 认证 Token 响应 + */ + AuthenticationToken refreshToken(String token); + + /** + * 令 Token 失效 + * + * @param token Token + */ + default void invalidateToken(String token) { + // 默认实现可以是空的,或者抛出不支持的操作异常 + // throw new UnsupportedOperationException("Not implemented"); + } + + /** + * 使指定用户的所有会话失效 + * + * @param userId 用户ID + */ + default void invalidateUserSessions(Long userId) { + // 默认空实现,由具体 TokenManager 决定是否支持按用户下线 + } + +} 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 new file mode 100644 index 0000000..3fed56b --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/util/SecurityUtils.java @@ -0,0 +1,126 @@ +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 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 工具类 + * + * @author Ray + * @since 2021/1/10 + */ +public class SecurityUtils { + + /** + * 获取当前登录人信息 + * + * @return Optional + */ + 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); + } + } + return Optional.empty(); + } + + + /** + * 获取用户ID + * + * @return Long + */ + public static Long getUserId() { + return getUser().map(SysUserDetails::getUserId).orElse(null); + } + + + /** + * 获取用户账号 + * + * @return String 用户账号 + */ + public static String getUsername() { + return getUser().map(SysUserDetails::getUsername).orElse(null); + } + + + /** + * 获取部门ID + * + * @return Long + */ + public static Long getDeptId() { + return getUser().map(SysUserDetails::getDeptId).orElse(null); + } + + /** + * 获取数据权限列表 + * + * @return 数据权限列表 + */ + public static List getDataScopes() { + return getUser().map(SysUserDetails::getDataScopes).orElse(List.of()); + } + + /** + * 获取角色集合 + * + * @return 角色集合 + */ + 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()); + } + + /** + * 是否超级管理员 + *

+ * 超级管理员忽视任何权限判断 + */ + public static boolean isRoot() { + Set roles = getRoles(); + return roles.contains(SystemConstants.ROOT_ROLE_CODE); + } + + /** + * 获取请求中的 Token + * + * @return Token 字符串 + */ + public static String getAccessToken() { + ServletRequestAttributes servletRequestAttributes = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()); + if(Objects.isNull(servletRequestAttributes)) { + return null; + } + HttpServletRequest request = servletRequestAttributes.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 new file mode 100644 index 0000000..fb0e25d --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/web/advice/GlobalExceptionHandler.java @@ -0,0 +1,279 @@ +package com.youlai.boot.framework.web.advice; + +import cn.hutool.core.util.StrUtil; +import tools.jackson.core.JacksonException; +import com.youlai.boot.common.exception.BusinessException; +import com.youlai.boot.common.result.Result; +import com.youlai.boot.common.result.ResultCode; +import jakarta.servlet.ServletException; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.TypeMismatchException; +import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.http.HttpStatus; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.jdbc.BadSqlGrammarException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.validation.BindException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.NoHandlerFoundException; + +import java.sql.SQLIntegrityConstraintViolationException; +import java.sql.SQLSyntaxErrorException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * 全局系统异常处理器 + *

+ * 调整异常处理的HTTP状态码,丰富异常处理类型 + */ +@RestControllerAdvice +@Slf4j +public class GlobalExceptionHandler { + + /** + * 处理绑定异常 + *

+ * 当请求参数绑定到对象时发生错误,会抛出 BindException 异常。 + */ + @ExceptionHandler(BindException.class) + @ResponseStatus(HttpStatus.OK) + public Result processException(BindException e) { + log.error("BindException:{}", e.getMessage()); + String msg = e.getAllErrors().stream().map(DefaultMessageSourceResolvable::getDefaultMessage).collect(Collectors.joining(";")); + return Result.failed(ResultCode.USER_REQUEST_PARAMETER_ERROR, msg); + } + + /** + * 处理 @RequestParam 参数校验异常 + *

+ * 当请求参数在校验过程中发生违反约束条件的异常时(如 @RequestParam 验证不通过), + * 会捕获到 ConstraintViolationException 异常。 + */ + @ExceptionHandler(ConstraintViolationException.class) + @ResponseStatus(HttpStatus.OK) + public Result processException(ConstraintViolationException e) { + log.error("ConstraintViolationException:{}", e.getMessage()); + String msg = e.getConstraintViolations().stream().map(ConstraintViolation::getMessage).collect(Collectors.joining(";")); + return Result.failed(ResultCode.INVALID_USER_INPUT, msg); + } + + /** + * 处理方法参数校验异常 + *

+ * 当使用 @Valid 或 @Validated 注解对方法参数进行验证时,如果验证失败, + * 会抛出 MethodArgumentNotValidException 异常。 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.OK) + public Result processException(MethodArgumentNotValidException e) { + log.error("MethodArgumentNotValidException:{}", e.getMessage()); + String msg = e.getBindingResult().getAllErrors().stream().map(DefaultMessageSourceResolvable::getDefaultMessage).collect(Collectors.joining(";")); + return Result.failed(ResultCode.INVALID_USER_INPUT, msg); + } + + /** + * 处理接口不存在的异常 + *

+ * 当客户端请求一个不存在的路径时,会抛出 NoHandlerFoundException 异常。 + */ + @ExceptionHandler(NoHandlerFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public Result processException(NoHandlerFoundException e) { + log.error(e.getMessage(), e); + return Result.failed(ResultCode.INTERFACE_NOT_EXIST); + } + + /** + * 处理缺少请求参数的异常 + *

+ * 当请求缺少必需的参数时,会抛出 MissingServletRequestParameterException 异常。 + */ + @ExceptionHandler(MissingServletRequestParameterException.class) + @ResponseStatus(HttpStatus.OK) + public Result processException(MissingServletRequestParameterException e) { + log.error(e.getMessage(), e); + return Result.failed(ResultCode.REQUEST_REQUIRED_PARAMETER_IS_EMPTY); + } + + /** + * 处理方法参数类型不匹配的异常 + *

+ * 当请求参数类型不匹配时,会抛出 MethodArgumentTypeMismatchException 异常。 + */ + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + @ResponseStatus(HttpStatus.OK) + public Result processException(MethodArgumentTypeMismatchException e) { + log.error(e.getMessage(), e); + return Result.failed(ResultCode.PARAMETER_FORMAT_MISMATCH, "类型错误"); + } + + /** + * 处理 Servlet 异常 + *

+ * 当 Servlet 处理请求时发生异常时,会抛出 ServletException 异常。 + */ + @ExceptionHandler(ServletException.class) + @ResponseStatus(HttpStatus.OK) + public Result processException(ServletException e) { + log.error(e.getMessage(), e); + return Result.failed(e.getMessage()); + } + + /** + * 处理非法参数异常 + *

+ * 当方法接收到非法参数时,会抛出 IllegalArgumentException 异常。 + */ + @ExceptionHandler(IllegalArgumentException.class) + @ResponseStatus(HttpStatus.OK) + public Result handleIllegalArgumentException(IllegalArgumentException e) { + log.error("非法参数异常,异常原因:{}", e.getMessage(), e); + return Result.failed(e.getMessage()); + } + + /** + * 处理 JSON 处理异常 + *

+ * 当处理 JSON 数据时发生错误,会抛出 JacksonException 异常。 + */ + @ExceptionHandler(JacksonException.class) + @ResponseStatus(HttpStatus.OK) + public Result handleJacksonException(JacksonException e) { + log.error("Json转换异常,异常原因:{}", e.getMessage(), e); + return Result.failed(e.getMessage()); + } + + /** + * 处理请求体不可读的异常 + *

+ * 当请求体不可读时,会抛出 HttpMessageNotReadableException 异常。 + */ + @ExceptionHandler(HttpMessageNotReadableException.class) + @ResponseStatus(HttpStatus.OK) + public Result processException(HttpMessageNotReadableException e) { + log.error(e.getMessage(), e); + String errorMessage = "请求体不可为空"; + Throwable cause = e.getCause(); + if (cause != null) { + errorMessage = convertMessage(cause); + } + return Result.failed(errorMessage); + } + + /** + * 处理类型不匹配异常 + *

+ * 当方法参数类型不匹配时,会抛出 TypeMismatchException 异常。 + */ + @ExceptionHandler(TypeMismatchException.class) + @ResponseStatus(HttpStatus.OK) + public Result processException(TypeMismatchException e) { + log.error(e.getMessage(), e); + return Result.failed(e.getMessage()); + } + + /** + * 处理 SQL 语法错误异常 + *

+ * 当 SQL 语法错误时,会抛出 BadSqlGrammarException 异常。 + */ + @ExceptionHandler(BadSqlGrammarException.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public Result handleBadSqlGrammarException(BadSqlGrammarException e) { + log.error(e.getMessage(), e); + String errorMsg = e.getMessage(); + if (StrUtil.isNotBlank(errorMsg) && errorMsg.contains("denied to user")) { + return Result.failed(ResultCode.DATABASE_ACCESS_DENIED); + } else { + return Result.failed(e.getMessage()); + } + } + + /** + * 处理 SQL 语法错误异常 + *

+ * 当 SQL 语法错误时,会抛出 SQLSyntaxErrorException 异常。 + */ + @ExceptionHandler(SQLSyntaxErrorException.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public Result processSQLSyntaxErrorException(SQLSyntaxErrorException e) { + log.error(e.getMessage(), e); + return Result.failed(ResultCode.DATABASE_EXECUTION_SYNTAX_ERROR); + } + + + /** + * 处理 SQL 违反了完整性约束 + *

+ * 当 SQL 违反了完整性约束时,会抛出 SQLIntegrityConstraintViolationException 异常。 + */ + @ExceptionHandler(SQLIntegrityConstraintViolationException.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public Result handleSQLIntegrityConstraintViolationException(SQLIntegrityConstraintViolationException e) { + log.error(e.getMessage(), e); + return Result.failed(ResultCode.INTEGRITY_CONSTRAINT_VIOLATION); + } + + /** + * 处理业务异常 + *

+ * 当业务逻辑发生错误时,会抛出 BusinessException 异常。 + */ + @ExceptionHandler(BusinessException.class) + @ResponseStatus(HttpStatus.OK) + public Result handleBizException(BusinessException e) { + log.error("biz exception", e); + if (e.getResultCode() != null) { + return Result.failed(e.getResultCode(), e.getMessage()); + } + return Result.failed(e.getMessage()); + } + + /** + * 处理所有未捕获的异常 + *

+ * 当发生未捕获的异常时,会抛出 Exception 异常。 + */ + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public Result handleException(Exception e) throws Exception { + // 将 Spring Security 异常继续抛出,以便交给自定义处理器处理 + if (e instanceof AccessDeniedException + || e instanceof AuthenticationException) { + throw e; + } + log.error("unknown exception", e); + return Result.failed(e.getLocalizedMessage()); + } + + /** + * 传参类型错误时,用于消息转换 + * + * @param throwable 异常 + * @return 错误信息 + */ + private String convertMessage(Throwable throwable) { + String error = throwable.toString(); + String regulation = "\\[\"(.*?)\"]+"; + Pattern pattern = Pattern.compile(regulation); + Matcher matcher = pattern.matcher(error); + String group = ""; + if (matcher.find()) { + String matchString = matcher.group(); + matchString = matchString.replace("[", "").replace("]", ""); + matchString = "%s字段类型错误".formatted(matchString.replaceAll("\"", "")); + group += matchString; + } + return group; + } +} diff --git a/src/main/java/com/youlai/boot/framework/web/config/CorsConfig.java b/src/main/java/com/youlai/boot/framework/web/config/CorsConfig.java new file mode 100644 index 0000000..38eb6d0 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/web/config/CorsConfig.java @@ -0,0 +1,42 @@ +package com.youlai.boot.framework.web.config; + +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +import java.util.Collections; + +/** + * CORS 资源共享配置 + * + * @author haoxr + * @since 2023/4/17 + */ +@Configuration +public class CorsConfig { + + @Bean + public FilterRegistrationBean filterRegistrationBean() { + CorsConfiguration corsConfiguration = new CorsConfiguration(); + //1.允许任何来源 + corsConfiguration.setAllowedOriginPatterns(Collections.singletonList("*")); + //2.允许任何请求头 + corsConfiguration.addAllowedHeader(CorsConfiguration.ALL); + //3.允许任何方法 + corsConfiguration.addAllowedMethod(CorsConfiguration.ALL); + //4.允许凭证 + corsConfiguration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", corsConfiguration); + CorsFilter corsFilter = new CorsFilter(source); + + FilterRegistrationBean filterRegistrationBean=new FilterRegistrationBean<>(corsFilter); + filterRegistrationBean.setOrder(-101); // 小于 SpringSecurity Filter的 Order(-100) 即可 + + return filterRegistrationBean; + } +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/framework/web/config/JacksonConfig.java b/src/main/java/com/youlai/boot/framework/web/config/JacksonConfig.java new file mode 100644 index 0000000..7ca0f63 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/web/config/JacksonConfig.java @@ -0,0 +1,47 @@ +package com.youlai.boot.framework.web.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import tools.jackson.databind.cfg.DateTimeFeature; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.module.SimpleModule; +import tools.jackson.databind.ser.std.ToStringSerializer; + +import java.math.BigInteger; +import java.text.SimpleDateFormat; +import java.util.TimeZone; + +/** + * Jackson 全局序列化配置 + * + *

本项目的统一序列化策略: + *
- 统一时区 GMT+8 统一日期格式 yyyy-MM-dd HH:mm:ss + *
- Long/BigInteger 序列化为字符串,避免前端精度丢失 + *
- 禁用 WRITE_DATES_AS_TIMESTAMPS,避免日期输出为时间戳

+ * + * @author Ray.Hao + * @since 2026/1/12 + */ +@Configuration +public class JacksonConfig { + + /** + * 全局 JsonMapper + * + *

由 Spring Boot 自动装配到 Jackson 相关的 HttpMessageConverter 中,作为全局 JSON 序列化/反序列化 + * 行为的唯一入口。

+ */ + @Bean + public JsonMapper objectMapper() { + return JsonMapper.builder() + .disable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS) + .defaultDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")) + .defaultTimeZone(TimeZone.getTimeZone("GMT+8")) + .addModule(new SimpleModule() + .addSerializer(Long.class, ToStringSerializer.instance) + .addSerializer(BigInteger.class, ToStringSerializer.instance) + ) + .build(); + } + +} diff --git a/src/main/java/com/youlai/boot/framework/web/filter/RateLimiterFilter.java b/src/main/java/com/youlai/boot/framework/web/filter/RateLimiterFilter.java new file mode 100644 index 0000000..5b86044 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/web/filter/RateLimiterFilter.java @@ -0,0 +1,98 @@ +package com.youlai.boot.framework.web.filter; + +import cn.hutool.core.convert.Convert; +import cn.hutool.core.util.StrUtil; +import com.youlai.boot.common.constant.RedisConstants; +import com.youlai.boot.common.constant.SystemConstants; +import com.youlai.boot.common.result.ResultCode; +import com.youlai.boot.common.util.IPUtils; +import com.youlai.boot.common.result.ResponseWriter; +import com.youlai.boot.system.service.ConfigService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +/** + * IP 限流过滤器 + * + * @author Theo + * @since 2024/08/10 14:38 + */ +@Slf4j +public class RateLimiterFilter extends OncePerRequestFilter { + + private final RedisTemplate redisTemplate; + private final ConfigService configService; + + private static final long DEFAULT_IP_LIMIT = 10L; // 默认 IP 限流阈值 + + public RateLimiterFilter(RedisTemplate redisTemplate, ConfigService configService) { + this.redisTemplate = redisTemplate; + this.configService = configService; + } + + /** + * 判断 IP 是否触发限流 + * 默认限制同一 IP 每秒最多请求 10 次,可通过系统配置调整。 + * 如果系统未配置限流阈值,默认跳过限流。 + * + * @param ip IP 地址 + * @return 是否限流:true 表示限流;false 表示未限流 + */ + public boolean rateLimit(String ip) { + // 限流 Redis 键 + String key = StrUtil.format(RedisConstants.RateLimiter.IP, ip); + + // 自增请求计数 + Long count = redisTemplate.opsForValue().increment(key); + if (count == null || count == 1) { + // 第一次访问时设置过期时间为 1 秒 + redisTemplate.expire(key, 1, TimeUnit.SECONDS); + } + + // 获取系统配置的限流阈值 + Object systemConfig = configService.getSystemConfig(SystemConstants.SYSTEM_CONFIG_IP_QPS_LIMIT_KEY); + if (systemConfig == null) { + // 系统未配置限流,跳过限流逻辑 + log.warn("系统未配置限流阈值,跳过限流"); + return false; + } + + // 转换系统配置为限流值,默认为 10 + long limit = Convert.toLong(systemConfig, DEFAULT_IP_LIMIT); + return count != null && count > limit; + } + + /** + * 执行 IP 限流逻辑 + * 如果 IP 请求超出限制,直接返回限流响应;否则继续执行过滤器链。 + * + * @param request 请求体 + * @param response 响应体 + * @param filterChain 过滤器链 + */ + @Override + protected void doFilterInternal(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, + @NotNull FilterChain filterChain) throws ServletException, IOException { + // 获取请求的 IP 地址 + String ip = IPUtils.getIpAddr(request); + + // 判断是否限流 + if (rateLimit(ip)) { + // 返回限流错误信息 + ResponseWriter.writeError(response, ResultCode.REQUEST_CONCURRENCY_LIMIT_EXCEEDED); + return; + } + + // 未触发限流,继续执行过滤器链 + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/youlai/boot/framework/web/filter/RequestLogFilter.java b/src/main/java/com/youlai/boot/framework/web/filter/RequestLogFilter.java new file mode 100644 index 0000000..f9ca070 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/web/filter/RequestLogFilter.java @@ -0,0 +1,38 @@ +package com.youlai.boot.framework.web.filter; + +import com.youlai.boot.common.util.IPUtils; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.filter.CommonsRequestLoggingFilter; + +/** + * 请求日志打印过滤器 + * + * @author haoxr + * @since 2023/03/03 + */ +@Configuration +@Slf4j +public class RequestLogFilter extends CommonsRequestLoggingFilter { + + @Override + protected boolean shouldLog(HttpServletRequest request) { + // 设置日志输出级别,默认debug + return this.logger.isInfoEnabled(); + } + + @Override + protected void beforeRequest(HttpServletRequest request, String message) { + String requestURI = request.getRequestURI(); + String ip = IPUtils.getIpAddr(request); + log.info("request,ip:{}, uri: {}", ip, requestURI); + super.beforeRequest(request, message); + } + + @Override + protected void afterRequest(HttpServletRequest request, String message) { + super.afterRequest(request, message); + } + +} diff --git a/src/main/java/com/youlai/boot/message/controller/SseController.java b/src/main/java/com/youlai/boot/message/controller/SseController.java new file mode 100644 index 0000000..f663c96 --- /dev/null +++ b/src/main/java/com/youlai/boot/message/controller/SseController.java @@ -0,0 +1,45 @@ +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.util.SecurityUtils; +import com.youlai.boot.message.service.SseService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +/** + * SSE 控制器 + */ +@Tag(name = "14. SSE连接") +@Slf4j +@RestController +@RequestMapping("/api/v1/sse") +@RequiredArgsConstructor +public class SseController { + + private final SseService sseService; + + @Operation(summary = "建立SSE连接") + @GetMapping(value = "/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter connect() { + SysUserDetails user = SecurityUtils.getUser().orElse(null); + if (user == null) { + log.warn("SSE连接失败:未获取到当前用户"); + return null; + } + return sseService.createConnection(user.getUsername()); + } + + @Operation(summary = "获取在线用户数") + @GetMapping("/online-count") + public Result getOnlineCount() { + return Result.success(sseService.getOnlineUserCount()); + } +} diff --git a/src/main/java/com/youlai/boot/message/dto/DictChangeEvent.java b/src/main/java/com/youlai/boot/message/dto/DictChangeEvent.java new file mode 100644 index 0000000..934f241 --- /dev/null +++ b/src/main/java/com/youlai/boot/message/dto/DictChangeEvent.java @@ -0,0 +1,42 @@ +package com.youlai.boot.message.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 字典变更事件 + *

+ * 当字典数据发生变更时,通过 SSE 广播此事件通知前端清除缓存。 + * 前端收到通知后清除对应字典的本地缓存,下次使用时重新从服务端加载。 + * + * @author Ray.Hao + * @since 3.0.0 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DictChangeEvent implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** 字典编码 */ + private String dictCode; + + /** 事件时间戳 */ + private long timestamp; + + /** + * 创建字典变更事件(自动设置当前时间戳) + * + * @param dictCode 字典编码 + */ + public DictChangeEvent(String dictCode) { + this.dictCode = dictCode; + this.timestamp = System.currentTimeMillis(); + } +} diff --git a/src/main/java/com/youlai/boot/message/dto/OnlineUserDTO.java b/src/main/java/com/youlai/boot/message/dto/OnlineUserDTO.java new file mode 100644 index 0000000..5752155 --- /dev/null +++ b/src/main/java/com/youlai/boot/message/dto/OnlineUserDTO.java @@ -0,0 +1,34 @@ +package com.youlai.boot.message.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 在线用户信息DTO + *

+ * 用于返回在线用户的基本信息,包括用户名、会话数量和登录时间。 + * + * @author Ray.Hao + * @since 3.0.0 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class OnlineUserDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** 用户名 */ + private String username; + + /** 会话数量(多设备登录时大于1) */ + private int sessionCount; + + /** 最早登录时间 */ + private long loginTime; +} diff --git a/src/main/java/com/youlai/boot/message/job/OnlineUserCountJob.java b/src/main/java/com/youlai/boot/message/job/OnlineUserCountJob.java new file mode 100644 index 0000000..14ecb42 --- /dev/null +++ b/src/main/java/com/youlai/boot/message/job/OnlineUserCountJob.java @@ -0,0 +1,38 @@ +package com.youlai.boot.message.job; + +import com.youlai.boot.message.registry.SseSessionRegistry; +import com.youlai.boot.message.service.SseService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 在线用户数统计定时任务 + *

+ * 定时统计并广播当前在线用户数量到所有 SSE 客户端 + */ +@Component +@Slf4j +@RequiredArgsConstructor +public class OnlineUserCountJob { + + private final SseSessionRegistry sessionRegistry; + private final SseService sseService; + + /** + * 定时统计在线用户数并广播 + *

+ * 每3分钟执行一次,推送当前在线用户数量 + */ + @Scheduled(cron = "0 */3 * * * ?") + public void execute() { + int onlineCount = sessionRegistry.getOnlineUserCount(); + int connectionCount = sessionRegistry.getTotalConnectionCount(); + + log.debug("定时统计:在线用户数={}, 总连接数={}", onlineCount, connectionCount); + + // 发送在线用户数量 + sseService.sendOnlineCount(); + } +} diff --git a/src/main/java/com/youlai/boot/message/registry/SseSessionRegistry.java b/src/main/java/com/youlai/boot/message/registry/SseSessionRegistry.java new file mode 100644 index 0000000..502ec3a --- /dev/null +++ b/src/main/java/com/youlai/boot/message/registry/SseSessionRegistry.java @@ -0,0 +1,223 @@ +package com.youlai.boot.message.registry; + +import com.youlai.boot.message.dto.OnlineUserDTO; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.ContextClosedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +/** + * SSE 会话注册表 + *

+ * 维护 SSE 连接的用户会话信息,支持多设备同时登录 + */ +@Slf4j +@Component +public class SseSessionRegistry { + + /** 用户名 -> SseEmitter 集合(支持多设备) */ + private final Map> userEmittersMap = new ConcurrentHashMap<>(); + + /** SseEmitter -> 用户名(快速定位用户) */ + private final Map emitterUserMap = new ConcurrentHashMap<>(); + + /** SseEmitter -> 连接时间 */ + private final Map emitterTimeMap = new ConcurrentHashMap<>(); + + /** + * 用户上线(建立 SSE 连接) + * + * @param username 用户名 + * @param emitter SseEmitter + */ + public void userConnected(String username, SseEmitter emitter) { + userEmittersMap.computeIfAbsent(username, k -> ConcurrentHashMap.newKeySet()).add(emitter); + emitterUserMap.put(emitter, username); + emitterTimeMap.put(emitter, System.currentTimeMillis()); + log.debug("用户[{}]SSE连接已建立", username); + + // 设置连接超时和完成回调 + emitter.onCompletion(() -> { + removeEmitter(emitter); + log.debug("用户[{}]SSE连接已完成", username); + }); + emitter.onTimeout(() -> { + removeEmitter(emitter); + log.debug("用户[{}]SSE连接超时", username); + }); + emitter.onError(e -> { + removeEmitter(emitter); + log.debug("用户[{}]SSE连接错误: {}", username, e.getMessage()); + }); + } + + /** + * 移除指定 emitter + */ + private void removeEmitter(SseEmitter emitter) { + String username = emitterUserMap.remove(emitter); + if (username == null) { + return; + } + emitterTimeMap.remove(emitter); + + Set emitters = userEmittersMap.get(username); + if (emitters != null) { + emitters.remove(emitter); + if (emitters.isEmpty()) { + userEmittersMap.remove(username); + log.debug("用户[{}]所有SSE连接已断开", username); + } + } + } + + /** + * 用户下线(断开所有 SSE 连接) + * + * @param username 用户名 + */ + public void userDisconnected(String username) { + Set emitters = userEmittersMap.remove(username); + if (emitters == null) { + return; + } + emitters.forEach(emitter -> { + emitterUserMap.remove(emitter); + emitterTimeMap.remove(emitter); + try { + emitter.complete(); + } catch (Exception ignored) { + } + }); + log.debug("用户[{}]已下线,移除{}个SSE连接", username, emitters.size()); + } + + /** + * 获取在线用户数量 + */ + public int getOnlineUserCount() { + return userEmittersMap.size(); + } + + /** + * 获取总连接数量(包括多设备) + */ + public int getTotalConnectionCount() { + return emitterUserMap.size(); + } + + /** + * 获取指定用户的连接数量 + */ + public int getUserConnectionCount(String username) { + Set emitters = userEmittersMap.get(username); + return emitters != null ? emitters.size() : 0; + } + + /** + * 检查用户是否在线 + */ + public boolean isUserOnline(String username) { + Set emitters = userEmittersMap.get(username); + return emitters != null && !emitters.isEmpty(); + } + + /** + * 获取所有在线用户列表 + */ + public List getOnlineUsers() { + return userEmittersMap.entrySet().stream() + .map(entry -> { + String username = entry.getKey(); + Set emitters = entry.getValue(); + long earliestTime = emitters.stream() + .map(emitterTimeMap::get) + .filter(t -> t != null) + .mapToLong(Long::longValue) + .min() + .orElse(System.currentTimeMillis()); + return new OnlineUserDTO(username, emitters.size(), earliestTime); + }) + .collect(Collectors.toList()); + } + + /** + * 获取所有活跃的 SseEmitter + */ + public Set getAllEmitters() { + return emitterUserMap.keySet(); + } + + /** + * 获取指定用户的所有 SseEmitter + */ + public Set getUserEmitters(String username) { + return userEmittersMap.get(username); + } + + /** + * 向指定 emitter 发送事件 + */ + public boolean sendEvent(SseEmitter emitter, String eventName, Object data) { + try { + emitter.send(SseEmitter.event() + .name(eventName) + .data(data)); + return true; + } catch (IOException e) { + log.warn("发送SSE事件失败: {}", e.getMessage()); + removeEmitter(emitter); + return false; + } + } + + /** + * 向所有连接广播事件 + */ + public void broadcast(String eventName, Object data) { + getAllEmitters().forEach(emitter -> sendEvent(emitter, eventName, data)); + } + + /** + * 向指定用户发送事件 + */ + public void sendToUser(String username, String eventName, Object data) { + Set emitters = userEmittersMap.get(username); + if (emitters != null) { + emitters.forEach(emitter -> sendEvent(emitter, eventName, data)); + } + } + + /** + * 容器关闭时主动断开所有 SSE 连接,避免阻塞应用停止 + */ + @Order(Ordered.HIGHEST_PRECEDENCE) + @EventListener(ContextClosedEvent.class) + public void destroy() { + int count = emitterUserMap.size(); + if (count == 0) { + return; + } + log.info("应用关闭,主动断开 {} 个SSE连接...", count); + emitterUserMap.keySet().forEach(emitter -> { + try { + emitter.complete(); + } catch (Exception ignored) { + } + }); + userEmittersMap.clear(); + emitterUserMap.clear(); + emitterTimeMap.clear(); + log.info("所有SSE连接已断开"); + } +} diff --git a/src/main/java/com/youlai/boot/message/service/SseService.java b/src/main/java/com/youlai/boot/message/service/SseService.java new file mode 100644 index 0000000..d4b59f2 --- /dev/null +++ b/src/main/java/com/youlai/boot/message/service/SseService.java @@ -0,0 +1,140 @@ +package com.youlai.boot.message.service; + +import com.youlai.boot.message.dto.DictChangeEvent; +import com.youlai.boot.message.dto.OnlineUserDTO; +import com.youlai.boot.message.registry.SseSessionRegistry; +import com.youlai.boot.message.topic.SseTopics; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * SSE 服务 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SseService { + + /** SSE 连接超时时间:30 分钟 */ + private static final long TIMEOUT = 30 * 60 * 1000L; + + private final SseSessionRegistry sessionRegistry; + + /** + * 创建 SSE 连接 + * + * @param username 用户名 + * @return SseEmitter + */ + public SseEmitter createConnection(String username) { + if (username == null || username.isEmpty()) { + log.warn("创建SSE连接失败:用户名为空"); + return null; + } + + // 创建 SseEmitter,设置超时时间 + SseEmitter emitter = new SseEmitter(TIMEOUT); + + // 注册用户连接 + sessionRegistry.userConnected(username, emitter); + + // 连接建立后立即发送在线用户数 + try { + emitter.send(SseEmitter.event() + .name(SseTopics.ONLINE_COUNT) + .data(sessionRegistry.getOnlineUserCount())); + } catch (IOException e) { + log.warn("发送初始在线用户数失败: {}", e.getMessage()); + } + + log.info("用户[{}]SSE连接已建立,当前在线用户数: {}", username, sessionRegistry.getOnlineUserCount()); + + // 发送在线用户数变更 + sendOnlineCount(); + + return emitter; + } + + /** + * 发送字典变更事件 + * + * @param dictCode 字典编码 + */ + public void sendDictChange(String dictCode) { + if (dictCode == null || dictCode.isEmpty()) { + log.warn("字典编码为空,跳过发送"); + return; + } + + DictChangeEvent event = new DictChangeEvent(dictCode); + sessionRegistry.broadcast(SseTopics.DICT, event); + log.info("已发送字典变更通知: dictCode={}", dictCode); + } + + /** + * 发送在线用户数 + */ + public void sendOnlineCount() { + int count = sessionRegistry.getOnlineUserCount(); + sessionRegistry.broadcast(SseTopics.ONLINE_COUNT, count); + log.debug("已发送在线用户数: {}", count); + } + + /** + * 发送消息给指定用户 + * + * @param username 用户名 + * @param eventName 事件名称 + * @param data 数据 + */ + public void sendToUser(String username, String eventName, Object data) { + if (username == null || username.isEmpty()) { + log.warn("用户名为空,无法发送消息"); + return; + } + sessionRegistry.sendToUser(username, eventName, data); + log.info("已向用户[{}]发送事件[{}]", username, eventName); + } + + /** + * 获取在线用户列表 + * + * @return 在线用户列表 + */ + public List getOnlineUsers() { + return sessionRegistry.getOnlineUsers(); + } + + /** + * 获取在线用户数 + * + * @return 在线用户数 + */ + public int getOnlineUserCount() { + return sessionRegistry.getOnlineUserCount(); + } + + /** + * 发送系统消息 + * + * @param message 消息内容 + */ + public void sendSystemMessage(String message) { + if (message == null || message.isEmpty()) { + return; + } + Map systemMessage = Map.of( + "sender", "系统通知", + "content", message, + "timestamp", System.currentTimeMillis() + ); + sessionRegistry.broadcast(SseTopics.SYSTEM, systemMessage); + log.info("已发送系统消息: {}", message); + } +} diff --git a/src/main/java/com/youlai/boot/message/topic/SseTopics.java b/src/main/java/com/youlai/boot/message/topic/SseTopics.java new file mode 100644 index 0000000..0b48b51 --- /dev/null +++ b/src/main/java/com/youlai/boot/message/topic/SseTopics.java @@ -0,0 +1,19 @@ +package com.youlai.boot.message.topic; + +/** + * SSE 主题常量 + */ +public final class SseTopics { + + private SseTopics() { + } + + /** 字典变更事件 */ + public static final String DICT = "dict"; + + /** 在线用户数事件 */ + public static final String ONLINE_COUNT = "online-count"; + + /** 系统消息事件 */ + public static final String SYSTEM = "system"; +} diff --git a/src/main/java/com/youlai/boot/system/controller/ConfigController.java b/src/main/java/com/youlai/boot/system/controller/ConfigController.java new file mode 100644 index 0000000..5473f01 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/controller/ConfigController.java @@ -0,0 +1,88 @@ +package com.youlai.boot.system.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +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.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 io.swagger.v3.oas.annotations.Parameter; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.web.bind.annotation.*; +import org.springframework.security.access.prepost.PreAuthorize; + +/** + * 系统配置前端控制层 + * + * @author Theo + * @since 2024-07-30 11:25 + */ +@Slf4j +@RestController +@RequiredArgsConstructor +@Tag(name = "08.系统配置") +@RequestMapping("/api/v1/configs") +public class ConfigController { + + private final ConfigService configService; + + @Operation(summary = "系统配置分页列表") + @GetMapping + @PreAuthorize("@ss.hasPerm('sys:config:list')") + @Log(module = LogModuleEnum.CONFIG, value = ActionTypeEnum.LIST) + public PageResult page(@ParameterObject ConfigQuery queryParams) { + IPage result = configService.page(queryParams); + return PageResult.success(result); + } + + @Operation(summary = "新增系统配置") + @PostMapping + @PreAuthorize("@ss.hasPerm('sys:config:create')") + @Log(module = LogModuleEnum.CONFIG, value = ActionTypeEnum.INSERT) + public Result save(@RequestBody @Valid ConfigForm configForm) { + return Result.judge(configService.save(configForm)); + } + + @Operation(summary = "获取系统配置表单数据") + @GetMapping("/{id}/form") + public Result getConfigForm( + @Parameter(description = "系统配置ID") @PathVariable Long id + ) { + ConfigForm formData = configService.getConfigFormData(id); + return Result.success(formData); + } + + @Operation(summary = "刷新系统配置缓存") + @PutMapping("/refresh") + @PreAuthorize("@ss.hasPerm('sys:config:refresh')") + @Log(module = LogModuleEnum.CONFIG, value = ActionTypeEnum.UPDATE) + public Result refreshCache() { + return Result.judge(configService.refreshCache()); + } + + @Operation(summary = "修改系统配置") + @PutMapping(value = "/{id}") + @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)); + } + + @Operation(summary = "删除系统配置") + @DeleteMapping("/{id}") + @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)); + } + +} diff --git a/src/main/java/com/youlai/boot/system/controller/DeptController.java b/src/main/java/com/youlai/boot/system/controller/DeptController.java new file mode 100644 index 0000000..9b12c4a --- /dev/null +++ b/src/main/java/com/youlai/boot/system/controller/DeptController.java @@ -0,0 +1,98 @@ +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.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.system.service.DeptService; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import java.util.List; + +/** + * 部门控制器 + * + * @author Ray.Hao + * @since 2020/11/6 + */ +@Tag(name = "06.部门接口") +@RestController +@RequestMapping("/api/v1/depts") +@RequiredArgsConstructor +public class DeptController { + + private final DeptService deptService; + + @Operation(summary = "部门列表") + @GetMapping + @Log(module = LogModuleEnum.DEPT, value = ActionTypeEnum.LIST) + public Result> getDeptList( + DeptQuery queryParams + ) { + List list = deptService.getDeptList(queryParams); + return Result.success(list); + } + + @Operation(summary = "部门下拉列表") + @GetMapping("/options") + public Result>> getDeptOptions() { + List> list = deptService.listDeptOptions(); + return Result.success(list); + } + + @Operation(summary = "新增部门") + @PostMapping + @PreAuthorize("@ss.hasPerm('sys:dept:create')") + @RepeatSubmit + @Log(module = LogModuleEnum.DEPT, value = ActionTypeEnum.INSERT) + public Result saveDept( + @Valid @RequestBody DeptForm formData + ) { + Long id = deptService.saveDept(formData); + return Result.success(id); + } + + @Operation(summary = "获取部门表单数据") + @GetMapping("/{deptId}/form") + public Result getDeptForm( + @Parameter(description ="部门ID") @PathVariable Long deptId + ) { + DeptForm deptForm = deptService.getDeptForm(deptId); + return Result.success(deptForm); + } + + @Operation(summary = "修改部门") + @PutMapping(value = "/{deptId}") + @PreAuthorize("@ss.hasPerm('sys:dept:update')") + @Log(module = LogModuleEnum.DEPT, value = ActionTypeEnum.UPDATE) + public Result updateDept( + @PathVariable Long deptId, + @Valid @RequestBody DeptForm formData + ) { + deptId = deptService.updateDept(deptId, formData); + return Result.success(deptId); + } + + @Operation(summary = "删除部门") + @DeleteMapping("/{ids}") + @PreAuthorize("@ss.hasPerm('sys:dept:delete')") + @Log(module = LogModuleEnum.DEPT, value = ActionTypeEnum.DELETE) + public Result deleteDepartments( + @Parameter(description ="部门ID,多个以英文逗号(,)分割") @PathVariable("ids") String ids + ) { + boolean result = deptService.deleteByIds(ids); + return Result.judge(result); + } + +} diff --git a/src/main/java/com/youlai/boot/system/controller/DictController.java b/src/main/java/com/youlai/boot/system/controller/DictController.java new file mode 100644 index 0000000..9b1b555 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/controller/DictController.java @@ -0,0 +1,220 @@ +package com.youlai.boot.system.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +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.common.enums.ActionTypeEnum; +import com.youlai.boot.common.enums.LogModuleEnum; +import com.youlai.boot.system.model.form.DictItemForm; +import com.youlai.boot.system.model.query.DictItemQuery; +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.system.model.form.DictForm; +import com.youlai.boot.common.annotation.Log; +import com.youlai.boot.system.service.DictItemService; +import com.youlai.boot.system.service.DictService; +import com.youlai.boot.message.service.SseService; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; + +/** + * 字典控制层 + * + * @author Ray.Hao + * @since 2.9.0 + */ +@Tag(name = "07.字典接口") +@RestController +@RequestMapping("/api/v1/dicts") +@RequiredArgsConstructor +public class DictController { + + private final DictService dictService; + private final DictItemService dictItemService; + private final SseService sseService; + + //--------------------------------------------------- + // 字典相关接口 + //--------------------------------------------------- + @Operation(summary = "字典分页列表") + @GetMapping + @Log(module = LogModuleEnum.DICT, value = ActionTypeEnum.LIST) + public PageResult getDictPage( + DictQuery queryParams + ) { + Page result = dictService.getDictPage(queryParams); + return PageResult.success(result); + } + + + @Operation(summary = "字典列表") + @GetMapping("/options") + public Result>> getDictList() { + List> list = dictService.getDictList(); + return Result.success(list); + } + + @Operation(summary = "获取字典表单数据") + @GetMapping("/{id}/form") + public Result getDictForm( + @Parameter(description = "字典ID") @PathVariable Long id + ) { + DictForm formData = dictService.getDictForm(id); + return Result.success(formData); + } + + @Operation(summary = "新增字典") + @PostMapping + @PreAuthorize("@ss.hasPerm('sys:dict:create')") + @RepeatSubmit + @Log(module = LogModuleEnum.DICT, value = ActionTypeEnum.INSERT) + public Result saveDict(@Valid @RequestBody DictForm formData) { + boolean result = dictService.saveDict(formData); + // 发送字典更新通知 + if (result) { + sseService.sendDictChange(formData.getDictCode()); + } + return Result.judge(result); + } + + @Operation(summary = "修改字典") + @PutMapping("/{id}") + @PreAuthorize("@ss.hasPerm('sys:dict:update')") + @Log(module = LogModuleEnum.DICT, value = ActionTypeEnum.UPDATE) + public Result updateDict( + @PathVariable Long id, + @RequestBody DictForm dictForm + ) { + boolean status = dictService.updateDict(id, dictForm); + // 发送字典更新通知 + if (status && dictForm.getDictCode() != null) { + sseService.sendDictChange(dictForm.getDictCode()); + } + return Result.judge(status); + } + + @Operation(summary = "删除字典") + @DeleteMapping("/{ids}") + @PreAuthorize("@ss.hasPerm('sys:dict:delete')") + @Log(module = LogModuleEnum.DICT, value = ActionTypeEnum.DELETE) + public Result deleteDictionaries( + @Parameter(description = "字典ID,多个以英文逗号(,)拼接") @PathVariable String ids + ) { + // 获取字典编码列表,用于发送删除通知 + List dictCodes = dictService.getDictCodesByIds(Arrays.stream(ids.split(",")).toList()); + + dictService.deleteDictByIds(Arrays.stream(ids.split(",")).toList()); + + // 发送字典删除通知 + for (String dictCode : dictCodes) { + sseService.sendDictChange(dictCode); + } + + return Result.success(); + } + + + //--------------------------------------------------- + // 字典项相关接口 + //--------------------------------------------------- + @Operation(summary = "字典项分页列表") + @GetMapping("/{dictCode}/items") + public PageResult getDictItemPage( + @PathVariable String dictCode, + DictItemQuery queryParams + ) { + queryParams.setDictCode(dictCode); + Page result = dictItemService.getDictItemPage(queryParams); + return PageResult.success(result); + } + + @Operation(summary = "字典项列表") + @GetMapping("/{dictCode}/items/options") + public Result> getDictItems( + @Parameter(description = "字典编码") @PathVariable String dictCode + ) { + List list = dictItemService.getDictItems(dictCode); + return Result.success(list); + } + + @Operation(summary = "新增字典项") + @PostMapping("/{dictCode}/items") + @PreAuthorize("@ss.hasPerm('sys:dict-item:create')") + @RepeatSubmit + @Log(module = LogModuleEnum.DICT, value = ActionTypeEnum.INSERT) + public Result saveDictItem( + @PathVariable String dictCode, + @Valid @RequestBody DictItemForm formData + ) { + formData.setDictCode(dictCode); + boolean result = dictItemService.saveDictItem(formData); + + // 发送字典更新通知 + if (result) { + sseService.sendDictChange(dictCode); + } + + return Result.judge(result); + } + + @Operation(summary = "字典项表单数据") + @GetMapping("/{dictCode}/items/{itemId}/form") + public Result getDictItemForm( + @PathVariable String dictCode, + @Parameter(description = "字典项ID") @PathVariable Long itemId + ) { + DictItemForm formData = dictItemService.getDictItemForm(itemId); + return Result.success(formData); + } + + @Operation(summary = "修改字典项") + @PutMapping("/{dictCode}/items/{itemId}") + @PreAuthorize("@ss.hasPerm('sys:dict-item:update')") + @RepeatSubmit + @Log(module = LogModuleEnum.DICT, value = ActionTypeEnum.UPDATE) + public Result updateDictItem( + @PathVariable String dictCode, + @PathVariable Long itemId, + @RequestBody DictItemForm formData + ) { + formData.setId(itemId); + formData.setDictCode(dictCode); + boolean status = dictItemService.updateDictItem(formData); + + // 发送字典更新通知 + if (status) { + sseService.sendDictChange(dictCode); + } + + return Result.judge(status); + } + + @Operation(summary = "删除字典项") + @DeleteMapping("/{dictCode}/items/{itemIds}") + @PreAuthorize("@ss.hasPerm('sys:dict-item:delete')") + @Log(module = LogModuleEnum.DICT, value = ActionTypeEnum.DELETE) + public Result deleteDictItems( + @PathVariable String dictCode, + @Parameter(description = "字典ID,多个以英文逗号(,)拼接") @PathVariable String itemIds + ) { + dictItemService.deleteDictItemByIds(itemIds); + + // 发送字典更新通知 + sseService.sendDictChange(dictCode); + + return Result.success(); + } + +} 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 0000000..a3cec6e --- /dev/null +++ b/src/main/java/com/youlai/boot/system/controller/HealthController.java @@ -0,0 +1,16 @@ +package com.youlai.boot.system.controller; + +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Tag(name = "健康检查接口") +public class HealthController { + + @GetMapping("/healthcheck") + public String health(){ + return "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 new file mode 100644 index 0000000..5c3c319 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/controller/LogController.java @@ -0,0 +1,65 @@ +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.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.system.model.query.LogQuery; +import com.youlai.boot.system.model.vo.LogPageVO; +import com.youlai.boot.system.model.vo.VisitOverviewVO; +import com.youlai.boot.system.model.vo.VisitTrendVO; +import com.youlai.boot.system.service.LogService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; + +/** + * 日志控制层 + * + * @author Ray.Hao + * @since 2.10.0 + */ +@Tag(name = "10.日志接口") +@RestController +@RequestMapping("/api/v1/logs") +@RequiredArgsConstructor +public class LogController { + + private final LogService logService; + + @Operation(summary = "日志分页列表") + @GetMapping + @Log(module = LogModuleEnum.LOG, value = ActionTypeEnum.LIST) + public PageResult getLogPage( + LogQuery queryParams + ) { + Page result = logService.getLogPage(queryParams); + return PageResult.success(result); + } + + @Operation(summary = "访问趋势统计") + @GetMapping("/analytics/trend") + public Result getVisitTrend( + @Parameter(description = "开始时间", example = "2024-01-01") @RequestParam String startDate, + @Parameter(description = "结束时间", example = "2024-12-31") @RequestParam String endDate + ) { + LocalDate start = LocalDate.parse(startDate); + LocalDate end = LocalDate.parse(endDate); + VisitTrendVO data = logService.getVisitTrend(start, end); + return Result.success(data); + } + + @Operation(summary = "访问统计概览") + @GetMapping("/analytics/overview") + public Result getVisitOverview() { + VisitOverviewVO result = logService.getVisitStats(); + return Result.success(result); + } + +} diff --git a/src/main/java/com/youlai/boot/system/controller/MenuController.java b/src/main/java/com/youlai/boot/system/controller/MenuController.java new file mode 100644 index 0000000..63b0384 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/controller/MenuController.java @@ -0,0 +1,120 @@ +package com.youlai.boot.system.controller; + +import com.youlai.boot.common.annotation.Log; +import com.youlai.boot.common.annotation.RepeatSubmit; +import com.youlai.boot.common.enums.ActionTypeEnum; +import com.youlai.boot.common.enums.LogModuleEnum; +import com.youlai.boot.common.model.Option; +import com.youlai.boot.common.result.Result; +import com.youlai.boot.system.model.form.MenuForm; +import com.youlai.boot.system.model.query.MenuQuery; +import com.youlai.boot.system.model.vo.MenuVO; +import com.youlai.boot.system.model.vo.RouteVO; +import com.youlai.boot.system.service.MenuService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 菜单控制层 + * + * @author Ray.Hao + * @since 2020/11/06 + */ +@Tag(name = "05.菜单接口") +@RestController +@RequestMapping("/api/v1/menus") +@RequiredArgsConstructor +@Slf4j +public class MenuController { + + private final MenuService menuService; + + @Operation(summary = "菜单列表") + @GetMapping + @Log(module = LogModuleEnum.MENU, value = ActionTypeEnum.LIST) + public Result> getMenus(MenuQuery queryParams) { + List menuList = menuService.listMenus(queryParams); + return Result.success(menuList); + } + + @Operation(summary = "菜单下拉列表") + @GetMapping("/options") + public Result>> getMenuOptions( + @Parameter(description = "是否只查询父级菜单") + @RequestParam(required = false, defaultValue = "false") boolean onlyParent + ) { + List> menus = menuService.listMenuOptions(onlyParent); + return Result.success(menus); + } + + @Operation(summary = "当前用户菜单路由列表") + @GetMapping("/routes") + public Result> getCurrentUserRoutes() { + List routeList = menuService.listCurrentUserRoutes(); + return Result.success(routeList); + } + + @Operation(summary = "菜单表单数据") + @GetMapping("/{id}/form") + @PreAuthorize("@ss.hasPerm('sys:menu:update')") + public Result getMenuForm( + @Parameter(description = "菜单ID") @PathVariable Long id + ) { + MenuForm menu = menuService.getMenuForm(id); + return Result.success(menu); + } + + @Operation(summary = "新增菜单") + @PostMapping + @PreAuthorize("@ss.hasPerm('sys:menu:create')") + @RepeatSubmit + @Log(module = LogModuleEnum.MENU, value = ActionTypeEnum.INSERT) + public Result addMenu(@RequestBody MenuForm menuForm) { + boolean result = menuService.saveMenu(menuForm); + return Result.judge(result); + } + + @Operation(summary = "修改菜单") + @PutMapping(value = "/{id}") + @PreAuthorize("@ss.hasPerm('sys:menu:update')") + @Log(module = LogModuleEnum.MENU, value = ActionTypeEnum.UPDATE) + public Result updateMenu( + @RequestBody MenuForm menuForm + ) { + boolean result = menuService.saveMenu(menuForm); + return Result.judge(result); + } + + @Operation(summary = "删除菜单") + @DeleteMapping("/{id}") + @PreAuthorize("@ss.hasPerm('sys:menu:delete')") + @Log(module = LogModuleEnum.MENU, value = ActionTypeEnum.DELETE) + public Result deleteMenu( + @Parameter(description = "菜单ID,多个以英文(,)分割") @PathVariable("id") Long id + ) { + boolean result = menuService.deleteMenu(id); + return Result.judge(result); + } + + @Operation(summary = "修改菜单显示状态") + @PatchMapping("/{menuId}") + @PreAuthorize("@ss.hasPerm('sys:menu:update')") + @Log(module = LogModuleEnum.MENU, value = ActionTypeEnum.UPDATE) + public Result updateMenuVisible( + @Parameter(description = "菜单ID") @PathVariable Long menuId, + @Parameter(description = "显示状态(1:显示;0:隐藏)") Integer visible + + ) { + boolean result = menuService.updateMenuVisible(menuId, visible); + 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 new file mode 100644 index 0000000..f9feaff --- /dev/null +++ b/src/main/java/com/youlai/boot/system/controller/NoticeController.java @@ -0,0 +1,138 @@ +package com.youlai.boot.system.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.youlai.boot.common.annotation.Log; +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.system.model.form.NoticeForm; +import com.youlai.boot.system.model.query.NoticeQuery; +import com.youlai.boot.system.model.vo.NoticeDetailVO; +import com.youlai.boot.system.model.vo.NoticePageVO; +import com.youlai.boot.system.model.vo.UserNoticePageVO; +import com.youlai.boot.system.service.NoticeService; +import com.youlai.boot.system.service.UserNoticeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +/** + * 通知公告前端控制层 + * + * @author youlaitech + * @since 2024-08-27 10:31 + */ +@Tag(name = "09.通知公告") +@RestController +@RequestMapping("/api/v1/notices") +@RequiredArgsConstructor +public class NoticeController { + + private final NoticeService noticeService; + + private final UserNoticeService userNoticeService; + + @Operation(summary = "通知公告分页列表") + @GetMapping + @PreAuthorize("@ss.hasPerm('sys:notice:list')") + @Log(module = LogModuleEnum.NOTICE, value = ActionTypeEnum.LIST) + public PageResult getNoticePage(NoticeQuery queryParams) { + IPage result = noticeService.getNoticePage(queryParams); + return PageResult.success(result); + } + + @Operation(summary = "新增通知公告") + @PostMapping + @PreAuthorize("@ss.hasPerm('sys:notice:create')") + @Log(module = LogModuleEnum.NOTICE, value = ActionTypeEnum.INSERT) + public Result saveNotice(@RequestBody @Valid NoticeForm formData) { + boolean result = noticeService.saveNotice(formData); + return Result.judge(result); + } + + @Operation(summary = "获取通知公告表单数据") + @GetMapping("/{id}/form") + @PreAuthorize("@ss.hasPerm('sys:notice:update')") + public Result getNoticeForm( + @Parameter(description = "通知公告ID") @PathVariable Long id + ) { + NoticeForm formData = noticeService.getNoticeFormData(id); + return Result.success(formData); + } + + @Operation(summary = "阅读获取通知公告详情") + @GetMapping("/{id}/detail") + public Result getNoticeDetail( + @Parameter(description = "通知公告ID") @PathVariable Long id + ) { + NoticeDetailVO detailVo = noticeService.getNoticeDetail(id); + return Result.success(detailVo); + } + + @Operation(summary = "修改通知公告") + @PutMapping(value = "/{id}") + @PreAuthorize("@ss.hasPerm('sys:notice:update')") + @Log(module = LogModuleEnum.NOTICE, value = ActionTypeEnum.UPDATE) + public Result updateNotice( + @Parameter(description = "通知公告ID") @PathVariable Long id, + @RequestBody @Validated NoticeForm formData + ) { + boolean result = noticeService.updateNotice(id, formData); + return Result.judge(result); + } + + @Operation(summary = "发布通知公告") + @PutMapping("/{id}/publish") + @PreAuthorize("@ss.hasPerm('sys:notice:publish')") + @Log(module = LogModuleEnum.NOTICE, value = ActionTypeEnum.UPDATE) + public Result publishNotice( + @Parameter(description = "通知公告ID") @PathVariable Long id + ) { + boolean result = noticeService.publishNotice(id); + return Result.judge(result); + } + + @Operation(summary = "撤回通知公告") + @PutMapping("/{id}/revoke") + @PreAuthorize("@ss.hasPerm('sys:notice:revoke')") + @Log(module = LogModuleEnum.NOTICE, value = ActionTypeEnum.UPDATE) + public Result revokeNotice( + @Parameter(description = "通知公告ID") @PathVariable Long id + ) { + boolean result = noticeService.revokeNotice(id); + return Result.judge(result); + } + + @Operation(summary = "删除通知公告") + @DeleteMapping("/{ids}") + @PreAuthorize("@ss.hasPerm('sys:notice:delete')") + @Log(module = LogModuleEnum.NOTICE, value = ActionTypeEnum.DELETE) + public Result deleteNotices( + @Parameter(description = "通知公告ID,多个以英文逗号(,)分割") @PathVariable String ids + ) { + boolean result = noticeService.deleteNotices(ids); + return Result.judge(result); + } + + @Operation(summary = "全部已读") + @PutMapping("/read-all") + public Result readAll() { + userNoticeService.readAll(); + return Result.success(); + } + + @Operation(summary = "获取我的通知公告分页列表") + @GetMapping("/my") + public PageResult getMyNoticePage( + NoticeQuery queryParams + ) { + IPage result = noticeService.getMyNoticePage(queryParams); + return PageResult.success(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 new file mode 100644 index 0000000..86c28cf --- /dev/null +++ b/src/main/java/com/youlai/boot/system/controller/RoleController.java @@ -0,0 +1,139 @@ +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.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.system.service.RoleService; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; + +import java.util.List; + +/** + * 角色控制层 + * + * @author Ray.Hao + * @since 2022/10/16 + */ +@Tag(name = "04.角色接口") +@RestController +@RequestMapping("/api/v1/roles") +@RequiredArgsConstructor +public class RoleController { + + private final RoleService roleService; + + @Operation(summary = "角色分页列表") + @GetMapping + @Log(module = LogModuleEnum.ROLE, value = ActionTypeEnum.LIST) + public PageResult getRolePage( + RoleQuery queryParams + ) { + Page result = roleService.getRolePage(queryParams); + return PageResult.success(result); + } + + @Operation(summary = "角色下拉列表") + @GetMapping("/options") + public Result>> listRoleOptions() { + List> list = roleService.listRoleOptions(); + return Result.success(list); + } + + @Operation(summary = "新增角色") + @PostMapping + @PreAuthorize("@ss.hasPerm('sys:role:create')") + @RepeatSubmit + @Log(module = LogModuleEnum.ROLE, value = ActionTypeEnum.INSERT) + public Result addRole(@Valid @RequestBody RoleForm roleForm) { + boolean result = roleService.saveRole(roleForm); + return Result.judge(result); + } + + @Operation(summary = "获取角色表单数据") + @GetMapping("/{roleId}/form") + @PreAuthorize("@ss.hasPerm('sys:role:update')") + public Result getRoleForm( + @Parameter(description = "角色ID") @PathVariable Long roleId + ) { + RoleForm roleForm = roleService.getRoleForm(roleId); + return Result.success(roleForm); + } + + @Operation(summary = "修改角色") + @PutMapping(value = "/{id}") + @PreAuthorize("@ss.hasPerm('sys:role:update')") + @Log(module = LogModuleEnum.ROLE, value = ActionTypeEnum.UPDATE) + public Result updateRole(@Valid @RequestBody RoleForm roleForm) { + boolean result = roleService.saveRole(roleForm); + return Result.judge(result); + } + + @Operation(summary = "删除角色") + @DeleteMapping("/{ids}") + @PreAuthorize("@ss.hasPerm('sys:role:delete')") + @Log(module = LogModuleEnum.ROLE, value = ActionTypeEnum.DELETE) + public Result deleteRoles( + @Parameter(description = "删除角色,多个以英文逗号(,)拼接") @PathVariable String ids + ) { + roleService.deleteRoles(ids); + return Result.success(); + } + + @Operation(summary = "修改角色状态") + @PutMapping(value = "/{roleId}/status") + @PreAuthorize("@ss.hasPerm('sys:role:update')") + @Log(module = LogModuleEnum.ROLE, value = ActionTypeEnum.UPDATE) + public Result updateRoleStatus( + @Parameter(description = "角色ID") @PathVariable Long roleId, + @Parameter(description = "状态(1:启用;0:禁用)") @RequestParam Integer status + ) { + boolean result = roleService.updateRoleStatus(roleId, status); + return Result.judge(result); + } + + @Operation(summary = "获取角色的菜单ID集合") + @GetMapping("/{roleId}/menu-ids") + public Result> getRoleMenuIds( + @Parameter(description = "角色ID") @PathVariable Long roleId + ) { + List menuIds = roleService.getRoleMenuIds(roleId); + return Result.success(menuIds); + } + + @Operation(summary = "角色分配菜单权限") + @PutMapping("/{roleId}/menus") + @PreAuthorize("@ss.hasPerm('sys:role:assign')") + @Log(module = LogModuleEnum.ROLE, value = ActionTypeEnum.GRANT) + public Result assignMenusToRole( + @PathVariable Long roleId, + @RequestBody List menuIds + ) { + roleService.assignMenusToRole(roleId, menuIds); + return Result.success(); + } + + @Operation(summary = "获取角色的部门ID集合(自定义数据权限)") + @GetMapping("/{roleId}/dept-ids") + @PreAuthorize("@ss.hasPerm('sys:role:update')") + public Result> getRoleDeptIds( + @Parameter(description = "角色ID") @PathVariable Long roleId + ) { + List deptIds = roleService.getRoleDeptIds(roleId); + return Result.success(deptIds); + } +} diff --git a/src/main/java/com/youlai/boot/system/controller/UserController.java b/src/main/java/com/youlai/boot/system/controller/UserController.java new file mode 100644 index 0000000..75e7655 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/controller/UserController.java @@ -0,0 +1,278 @@ +package com.youlai.boot.system.controller; + +import cn.idev.excel.EasyExcel; +import cn.idev.excel.ExcelWriter; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.youlai.boot.common.annotation.Log; +import com.youlai.boot.common.annotation.RepeatSubmit; +import com.youlai.boot.common.enums.ActionTypeEnum; +import com.youlai.boot.common.enums.LogModuleEnum; +import com.youlai.boot.framework.security.util.SecurityUtils; +import com.youlai.boot.common.model.Option; +import com.youlai.boot.common.result.ExcelResult; +import com.youlai.boot.common.result.PageResult; +import com.youlai.boot.common.result.Result; +import com.youlai.boot.common.util.ExcelUtils; +import com.youlai.boot.system.listener.UserImportListener; +import com.youlai.boot.system.model.vo.UserExportVO; +import com.youlai.boot.system.model.form.UserImportForm; +import com.youlai.boot.system.model.entity.SysUser; +import com.youlai.boot.system.model.form.*; +import com.youlai.boot.system.model.query.UserQuery; +import com.youlai.boot.system.model.vo.CurrentUserVO; +import com.youlai.boot.system.model.vo.UserPageVO; +import com.youlai.boot.system.model.vo.UserProfileVO; +import com.youlai.boot.system.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * 用户控制层 + * + * @author Ray.Hao + * @since 2022/10/16 + */ +@Tag(name = "03.用户接口") +@RestController +@RequestMapping("/api/v1/users") +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + @Operation(summary = "用户列表") + @GetMapping + @Log(module = LogModuleEnum.USER, value = ActionTypeEnum.LIST) + public PageResult getUserList( + @Valid UserQuery queryParams + ) { + return PageResult.success(userService.getUserPage(queryParams)); + } + + @Operation(summary = "新增用户") + @PostMapping + @PreAuthorize("@ss.hasPerm('sys:user:create')") + @RepeatSubmit + @Log(module = LogModuleEnum.USER, value = ActionTypeEnum.INSERT) + public Result saveUser( + @RequestBody @Valid UserForm userForm + ) { + boolean result = userService.saveUser(userForm); + return Result.judge(result); + } + + @Operation(summary = "获取用户表单数据") + @GetMapping("/{userId}/form") + @PreAuthorize("@ss.hasPerm('sys:user:update')") + public Result getUserForm( + @Parameter(description = "用户ID") @PathVariable Long userId + ) { + UserForm formData = userService.getUserFormData(userId); + return Result.success(formData); + } + + @Operation(summary = "修改用户") + @PutMapping(value = "/{userId}") + @PreAuthorize("@ss.hasPerm('sys:user:update')") + @Log(module = LogModuleEnum.USER, value = ActionTypeEnum.UPDATE) + public Result updateUser( + @Parameter(description = "用户ID") @PathVariable Long userId, + @RequestBody @Valid UserForm userForm + ) { + boolean result = userService.updateUser(userId, userForm); + return Result.judge(result); + } + + @Operation(summary = "删除用户") + @DeleteMapping("/{ids}") + @PreAuthorize("@ss.hasPerm('sys:user:delete')") + @Log(module = LogModuleEnum.USER, value = ActionTypeEnum.DELETE) + public Result deleteUsers( + @Parameter(description = "用户ID,多个以英文逗号(,)分割") @PathVariable String ids + ) { + boolean result = userService.deleteUsers(ids); + return Result.judge(result); + } + + @Operation(summary = "修改用户状态") + @PatchMapping(value = "/{userId}/status") + @PreAuthorize("@ss.hasPerm('sys:user:update')") + @Log(module = LogModuleEnum.USER, value = ActionTypeEnum.UPDATE) + public Result updateUserStatus( + @Parameter(description = "用户ID") @PathVariable Long userId, + @Parameter(description = "用户状态(1:启用;0:禁用)") @RequestParam Integer status + ) { + boolean result = userService.update(new LambdaUpdateWrapper() + .eq(SysUser::getId, userId) + .set(SysUser::getStatus, status) + ); + return Result.judge(result); + } + + @Operation(summary = "重置指定用户密码") + @PutMapping(value = "/{userId}/password/reset") + @PreAuthorize("@ss.hasPerm('sys:user:reset-password')") + @Log(module = LogModuleEnum.USER, value = ActionTypeEnum.RESET_PASSWORD) + public Result resetUserPassword( + @Parameter(description = "用户ID") @PathVariable Long userId, + @RequestParam String password + ) { + boolean result = userService.resetUserPassword(userId, password); + return Result.judge(result); + } + + @Operation(summary = "获取当前登录用户信息") + @GetMapping("/me") + public Result getCurrentUser() { + CurrentUserVO currentUserVo = userService.getCurrentUserInfo(); + return Result.success(currentUserVo); + } + + @Operation(summary = "用户导入模板下载") + @GetMapping("/template") + public void downloadTemplate(HttpServletResponse response) { + String fileName = "用户导入模板.xlsx"; + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setHeader("Content-Disposition", "attachment; filename=" + URLEncoder.encode(fileName, StandardCharsets.UTF_8)); + + String fileClassPath = "templates" + File.separator + "excel" + File.separator + fileName; + InputStream inputStream = this.getClass().getClassLoader().getResourceAsStream(fileClassPath); + + try (ServletOutputStream outputStream = response.getOutputStream(); + ExcelWriter excelWriter = EasyExcel.write(outputStream).withTemplate(inputStream).build()) { + excelWriter.finish(); + } catch (IOException e) { + throw new RuntimeException("用户导入模板下载失败", e); + } + } + + @Operation(summary = "导入用户") + @PostMapping("/import") + @PreAuthorize("@ss.hasPerm('sys:user:import')") + @Log(module = LogModuleEnum.USER, value = ActionTypeEnum.IMPORT) + public Result importUsers(MultipartFile file) throws IOException { + UserImportListener listener = new UserImportListener(); + ExcelUtils.importExcel(file.getInputStream(), UserImportForm.class, listener); + return Result.success(listener.getExcelResult()); + } + + @Operation(summary = "导出用户") + @GetMapping("/export") + @PreAuthorize("@ss.hasPerm('sys:user:export')") + @Log(module = LogModuleEnum.USER, value = ActionTypeEnum.EXPORT) + public void exportUsers(UserQuery queryParams, HttpServletResponse response) throws IOException { + String fileName = "用户列表.xlsx"; + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setHeader("Content-Disposition", "attachment; filename=" + URLEncoder.encode(fileName, StandardCharsets.UTF_8)); + + List exportUserList = userService.listExportUsers(queryParams); + EasyExcel.write(response.getOutputStream(), UserExportVO.class).sheet("用户列表") + .doWrite(exportUserList); + } + + @Operation(summary = "获取用户下拉选项") + @GetMapping("/options") + public Result>> listUserOptions() { + List> list = userService.listUserOptions(); + return Result.success(list); + } + + @Operation(summary = "获取个人中心用户信息") + @GetMapping("/profile") + public Result getUserProfile() { + Long userId = SecurityUtils.getUserId(); + UserProfileVO userProfile = userService.getUserProfile(userId); + return Result.success(userProfile); + } + + @Operation(summary = "个人中心修改用户信息") + @PutMapping("/profile") + @Log(module = LogModuleEnum.USER, value = ActionTypeEnum.UPDATE) + public Result updateUserProfile(@RequestBody UserProfileForm formData) { + boolean result = userService.updateUserProfile(formData); + return Result.judge(result); + } + + + @Operation(summary = "当前用户修改密码") + @PutMapping(value = "/password") + @Log(module = LogModuleEnum.USER, value = ActionTypeEnum.CHANGE_PASSWORD) + public Result changeCurrentUserPassword( + @RequestBody PasswordUpdateForm data + ) { + Long currUserId = SecurityUtils.getUserId(); + boolean result = userService.changeUserPassword(currUserId, data); + return Result.judge(result); + } + + @Operation(summary = "发送短信验证码(绑定或更换手机号)") + @PostMapping(value = "/mobile/code") + public Result sendMobileCode( + @Parameter(description = "手机号码", required = true) @RequestParam String mobile + ) { + boolean result = userService.sendMobileCode(mobile); + return Result.judge(result); + } + + @Operation(summary = "绑定或更换手机号") + @PutMapping(value = "/mobile") + public Result bindOrChangeMobile( + @RequestBody @Validated MobileUpdateForm data + ) { + boolean result = userService.bindOrChangeMobile(data); + return Result.judge(result); + } + + @Operation(summary = "解绑手机号") + @DeleteMapping(value = "/mobile") + public Result unbindMobile( + @RequestBody @Validated PasswordVerifyForm data + ) { + boolean result = userService.unbindMobile(data); + return Result.judge(result); + } + + @Operation(summary = "发送邮箱验证码(绑定或更换邮箱)") + @PostMapping(value = "/email/code") + public Result sendEmailCode( + @Parameter(description = "邮箱地址", required = true) @RequestParam String email + ) { + userService.sendEmailCode(email); + return Result.success(); + } + + @Operation(summary = "绑定或更换邮箱") + @PutMapping(value = "/email") + public Result bindOrChangeEmail( + @RequestBody @Validated EmailUpdateForm data + ) { + boolean result = userService.bindOrChangeEmail(data); + return Result.judge(result); + } + + @Operation(summary = "解绑邮箱") + @DeleteMapping(value = "/email") + public Result unbindEmail( + @RequestBody @Validated PasswordVerifyForm data + ) { + boolean result = userService.unbindEmail(data); + return Result.judge(result); + } + +} diff --git a/src/main/java/com/youlai/boot/system/converter/ConfigConverter.java b/src/main/java/com/youlai/boot/system/converter/ConfigConverter.java new file mode 100644 index 0000000..dc448ec --- /dev/null +++ b/src/main/java/com/youlai/boot/system/converter/ConfigConverter.java @@ -0,0 +1,23 @@ +package com.youlai.boot.system.converter; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.system.model.entity.Config; +import com.youlai.boot.system.model.vo.ConfigVO; +import com.youlai.boot.system.model.form.ConfigForm; +import org.mapstruct.Mapper; + +/** + * 系统配置对象转换器 + * + * @author Theo + * @since 2024-7-29 11:42:49 + */ +@Mapper(componentModel = "spring") +public interface ConfigConverter { + + Page toPageVo(Page page); + + Config toEntity(ConfigForm configForm); + + ConfigForm toForm(Config entity); +} diff --git a/src/main/java/com/youlai/boot/system/converter/DeptConverter.java b/src/main/java/com/youlai/boot/system/converter/DeptConverter.java new file mode 100644 index 0000000..1ca1510 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/converter/DeptConverter.java @@ -0,0 +1,23 @@ +package com.youlai.boot.system.converter; + +import com.youlai.boot.system.model.entity.Dept; +import com.youlai.boot.system.model.vo.DeptVO; +import com.youlai.boot.system.model.form.DeptForm; +import org.mapstruct.Mapper; + +/** + * 部门对象转换器 + * + * @author haoxr + * @since 2022/7/29 + */ +@Mapper(componentModel = "spring") +public interface DeptConverter { + + DeptForm toForm(Dept entity); + + DeptVO toVo(Dept entity); + + Dept toEntity(DeptForm deptForm); + +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/system/converter/DictConverter.java b/src/main/java/com/youlai/boot/system/converter/DictConverter.java new file mode 100644 index 0000000..b15d23f --- /dev/null +++ b/src/main/java/com/youlai/boot/system/converter/DictConverter.java @@ -0,0 +1,23 @@ +package com.youlai.boot.system.converter; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.system.model.entity.Dict; +import com.youlai.boot.system.model.vo.DictPageVO; +import com.youlai.boot.system.model.form.DictForm; +import org.mapstruct.Mapper; + +/** + * 字典 对象转换器 + * + * @author Ray Hao + * @since 2022/6/8 + */ +@Mapper(componentModel = "spring") +public interface DictConverter { + + Page toPageVo(Page page); + + DictForm toForm(Dict entity); + + Dict toEntity(DictForm entity); +} diff --git a/src/main/java/com/youlai/boot/system/converter/DictItemConverter.java b/src/main/java/com/youlai/boot/system/converter/DictItemConverter.java new file mode 100644 index 0000000..99a354b --- /dev/null +++ b/src/main/java/com/youlai/boot/system/converter/DictItemConverter.java @@ -0,0 +1,29 @@ +package com.youlai.boot.system.converter; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.system.model.entity.DictItem; +import com.youlai.boot.system.model.form.DictItemForm; +import com.youlai.boot.system.model.vo.DictPageVO; +import com.youlai.boot.common.model.Option; +import org.mapstruct.Mapper; + +import java.util.List; + +/** + * 字典项对象转换器 + * + * @author Ray.Hao + * @since 2022/6/8 + */ +@Mapper(componentModel = "spring") +public interface DictItemConverter { + + Page toPageVo(Page page); + + DictItemForm toForm(DictItem entity); + + DictItem toEntity(DictItemForm formFata); + + Option toOption(DictItem dictItem); + List> toOption(List dictData); +} diff --git a/src/main/java/com/youlai/boot/system/converter/MenuConverter.java b/src/main/java/com/youlai/boot/system/converter/MenuConverter.java new file mode 100644 index 0000000..a360ed4 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/converter/MenuConverter.java @@ -0,0 +1,26 @@ +package com.youlai.boot.system.converter; + +import com.youlai.boot.system.model.entity.Menu; +import com.youlai.boot.system.model.vo.MenuVO; +import com.youlai.boot.system.model.form.MenuForm; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +/** + * 菜单对象转换器 + * + * @author Ray Hao + * @since 2024/5/26 + */ +@Mapper(componentModel = "spring") +public interface MenuConverter { + + MenuVO toVo(Menu entity); + + @Mapping(target = "params", ignore = true) + MenuForm toForm(Menu entity); + + @Mapping(target = "params", ignore = true) + Menu toEntity(MenuForm menuForm); + +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/system/converter/NoticeConverter.java b/src/main/java/com/youlai/boot/system/converter/NoticeConverter.java new file mode 100644 index 0000000..fdffda8 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/converter/NoticeConverter.java @@ -0,0 +1,27 @@ +package com.youlai.boot.system.converter; + +import com.youlai.boot.system.model.entity.Notice; +import com.youlai.boot.system.model.form.NoticeForm; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Mappings; + +/** + * 通知公告对象转换器 + * + * @author youlaitech + * @since 2024-08-27 10:31 + */ +@Mapper(componentModel = "spring") +public interface NoticeConverter { + + @Mappings({ + @Mapping(target = "targetUserIds", expression = "java(cn.hutool.core.util.StrUtil.split(entity.getTargetUserIds(),\",\"))") + }) + NoticeForm toForm(Notice entity); + + @Mappings({ + @Mapping(target = "targetUserIds", expression = "java(cn.hutool.core.collection.CollUtil.join(formData.getTargetUserIds(),\",\"))") + }) + Notice toEntity(NoticeForm formData); +} diff --git a/src/main/java/com/youlai/boot/system/converter/RoleConverter.java b/src/main/java/com/youlai/boot/system/converter/RoleConverter.java new file mode 100644 index 0000000..865782f --- /dev/null +++ b/src/main/java/com/youlai/boot/system/converter/RoleConverter.java @@ -0,0 +1,40 @@ +package com.youlai.boot.system.converter; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.system.model.entity.Role; +import com.youlai.boot.system.model.vo.RolePageVO; +import com.youlai.boot.common.model.Option; +import com.youlai.boot.system.model.form.RoleForm; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Mappings; + +import java.util.List; + +/** + * 角色对象转换器 + * + * @author haoxr + * @since 2022/5/29 + */ +@Mapper(componentModel = "spring") +public interface RoleConverter { + + @Mapping(target = "dataScope", source = "dataScope") + @Mapping(target = "dataScopeLabel", expression = "java(com.youlai.boot.common.enums.DataScopeEnum.getByValue(role.getDataScope()) == null ? null : com.youlai.boot.common.enums.DataScopeEnum.getByValue(role.getDataScope()).getLabel())") + RolePageVO toPageVo(Role role); + + Page toPageVo(Page page); + + @Mappings({ + @Mapping(target = "value", source = "id"), + @Mapping(target = "label", source = "name") + }) + Option toOption(Role role); + + List> toOptions(List roles); + + Role toEntity(RoleForm roleForm); + + RoleForm toForm(Role entity); +} diff --git a/src/main/java/com/youlai/boot/system/converter/UserConverter.java b/src/main/java/com/youlai/boot/system/converter/UserConverter.java new file mode 100644 index 0000000..806db61 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/converter/UserConverter.java @@ -0,0 +1,47 @@ +package com.youlai.boot.system.converter; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.common.model.Option; +import com.youlai.boot.system.model.entity.SysUser; +import com.youlai.boot.system.model.vo.CurrentUserVO; +import com.youlai.boot.system.model.form.UserForm; +import com.youlai.boot.system.model.form.UserImportForm; +import com.youlai.boot.system.model.form.UserProfileForm; +import org.mapstruct.InheritInverseConfiguration; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Mappings; + +import java.util.List; + +/** + * 用户对象转换器 + * + * @author Ray.Hao + * @since 2022/6/8 + */ +@Mapper(componentModel = "spring") +public interface UserConverter { + + UserForm toForm(SysUser entity); + + @InheritInverseConfiguration(name = "toForm") + SysUser toEntity(UserForm entity); + + @Mappings({ + @Mapping(target = "userId", source = "id") + }) + CurrentUserVO toCurrentUserVo(SysUser entity); + + SysUser toEntity(UserImportForm vo); + + SysUser toEntity(UserProfileForm formData); + + @Mappings({ + @Mapping(target = "label", source = "nickname"), + @Mapping(target = "value", source = "id") + }) + Option toOption(SysUser entity); + + List> toOptions(List list); +} diff --git a/src/main/java/com/youlai/boot/system/enums/DictCodeEnum.java b/src/main/java/com/youlai/boot/system/enums/DictCodeEnum.java new file mode 100644 index 0000000..0ccddd7 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/enums/DictCodeEnum.java @@ -0,0 +1,28 @@ +package com.youlai.boot.system.enums; + +import com.youlai.boot.common.base.IBaseEnum; +import lombok.Getter; + +/** + * 字典编码枚举 + * + * @author Ray.Hao + * @since 2024/10/30 + */ +@Getter +public enum DictCodeEnum implements IBaseEnum { + + GENDER("gender", "性别"), + NOTICE_TYPE("notice_type", "通知类型"), + NOTICE_LEVEL("notice_level", "通知级别"); + + private final String value; + + private final String label; + + DictCodeEnum(String value, String label) { + this.value = value; + this.label = label; + } + +} diff --git a/src/main/java/com/youlai/boot/system/enums/MenuTypeEnum.java b/src/main/java/com/youlai/boot/system/enums/MenuTypeEnum.java new file mode 100644 index 0000000..8745ff7 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/enums/MenuTypeEnum.java @@ -0,0 +1,37 @@ +package com.youlai.boot.system.enums; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.youlai.boot.common.base.IBaseEnum; +import lombok.Getter; + +/** + * 菜单类型枚举(char) + * + * C:目录 + * M:菜单 + * B:按钮 + */ +@Getter +public enum MenuTypeEnum implements IBaseEnum { + + CATALOG("C", "目录"), + MENU("M", "菜单"), + BUTTON("B", "按钮"); + + /** + * 数据库存储值 + */ + @EnumValue + private final String value; + + /** + * 友好名称 + */ + private final String label; + + MenuTypeEnum(String value, String label) { + this.value = value; + this.label = label; + } + +} diff --git a/src/main/java/com/youlai/boot/system/enums/NoticePublishStatusEnum.java b/src/main/java/com/youlai/boot/system/enums/NoticePublishStatusEnum.java new file mode 100644 index 0000000..bedd5e5 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/enums/NoticePublishStatusEnum.java @@ -0,0 +1,30 @@ +package com.youlai.boot.system.enums; + +import com.youlai.boot.common.base.IBaseEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +/** + * 通告发布状态枚举 + * + * @author Ray.Hao + * @since 2024/10/14 + */ +@Getter +@Schema(enumAsRef = true) +public enum NoticePublishStatusEnum implements IBaseEnum { + + UNPUBLISHED(0, "未发布"), + PUBLISHED(1, "已发布"), + REVOKED(-1, "已撤回"); + + + private final Integer value; + + private final String label; + + NoticePublishStatusEnum(Integer value, String label) { + this.value = value; + this.label = label; + } +} diff --git a/src/main/java/com/youlai/boot/system/enums/NoticeTargetEnum.java b/src/main/java/com/youlai/boot/system/enums/NoticeTargetEnum.java new file mode 100644 index 0000000..cbd9638 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/enums/NoticeTargetEnum.java @@ -0,0 +1,29 @@ +package com.youlai.boot.system.enums; + +import com.youlai.boot.common.base.IBaseEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +/** + * 通知目标类型枚举 + * + * @author Ray.Hao + * @since 2024/10/14 + */ +@Getter +@Schema(enumAsRef = true) +public enum NoticeTargetEnum implements IBaseEnum { + + ALL(1, "全体"), + SPECIFIED(2, "指定"); + + + private final Integer value; + + private final String label; + + NoticeTargetEnum(Integer value, String label) { + this.value = value; + this.label = label; + } +} diff --git a/src/main/java/com/youlai/boot/system/enums/SocialPlatformEnum.java b/src/main/java/com/youlai/boot/system/enums/SocialPlatformEnum.java new file mode 100644 index 0000000..0779f59 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/enums/SocialPlatformEnum.java @@ -0,0 +1,30 @@ +package com.youlai.boot.system.enums; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.youlai.boot.common.base.IBaseEnum; +import lombok.Getter; + +/** + * 第三方登录平台类型枚举 + */ +@Getter +public enum SocialPlatformEnum implements IBaseEnum { + + WECHAT_MINI("WECHAT_MINI", "微信小程序"), + WECHAT_MP("WECHAT_MP", "微信公众号"), + WECHAT_OPEN("WECHAT_OPEN", "微信开放平台"), + ALIPAY("ALIPAY", "支付宝"), + QQ("QQ", "QQ"), + APPLE("APPLE", "Apple ID"); + + @EnumValue + private final String value; + + private final String label; + + SocialPlatformEnum(String value, String label) { + this.value = value; + this.label = label; + } + +} diff --git a/src/main/java/com/youlai/boot/system/handler/XxlJobSampleHandler.java b/src/main/java/com/youlai/boot/system/handler/XxlJobSampleHandler.java new file mode 100644 index 0000000..6962878 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/handler/XxlJobSampleHandler.java @@ -0,0 +1,19 @@ +package com.youlai.boot.system.handler; + +import com.xxl.job.core.handler.annotation.XxlJob; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * xxl-job 测试示例(Bean模式) + */ +@Component +@Slf4j +public class XxlJobSampleHandler { + + @XxlJob("demoJobHandler") + public void demoJobHandler() { + log.info("XXL-JOB, Hello World."); + } + +} diff --git a/src/main/java/com/youlai/boot/system/listener/UserImportListener.java b/src/main/java/com/youlai/boot/system/listener/UserImportListener.java new file mode 100644 index 0000000..236a86e --- /dev/null +++ b/src/main/java/com/youlai/boot/system/listener/UserImportListener.java @@ -0,0 +1,225 @@ +package com.youlai.boot.system.listener; + +import cn.hutool.core.collection.CollectionUtil; +import cn.hutool.core.convert.Convert; +import cn.hutool.core.lang.Validator; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.hutool.json.JSONUtil; +import cn.idev.excel.context.AnalysisContext; +import cn.idev.excel.event.AnalysisEventListener; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.youlai.boot.common.constant.SystemConstants; +import com.youlai.boot.common.enums.StatusEnum; +import com.youlai.boot.common.result.ExcelResult; +import com.youlai.boot.system.converter.UserConverter; +import com.youlai.boot.system.enums.DictCodeEnum; +import com.youlai.boot.system.model.entity.SysUser; +import com.youlai.boot.system.model.entity.*; +import com.youlai.boot.system.model.form.UserImportForm; +import com.youlai.boot.system.service.*; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 用户导入监听器 + *

+ * 最简单的读的监听器 + * + * @author Ray + * @since 2022/4/10 + */ +@Slf4j +public class UserImportListener extends AnalysisEventListener { + + /** + * Excel 导入结果 + */ + @Getter + private final ExcelResult excelResult; + + private final UserService userService; + private final PasswordEncoder passwordEncoder; + private final UserConverter userConverter; + private final UserRoleService userRoleService; + + private final List roleList; + private final List deptList; + private final List genderList; + + /** + * 当前行 + */ + private Integer currentRow = 1; + + /** + * 构造方法 + *

在构造方法中给需要查询的内容查询好,尽量避免每条数据查询一次

+ */ + public UserImportListener() { + this.userService = SpringUtil.getBean(UserService.class); + this.passwordEncoder = SpringUtil.getBean(PasswordEncoder.class); + this.userRoleService = SpringUtil.getBean(UserRoleService.class); + this.userConverter = SpringUtil.getBean(UserConverter.class); + this.roleList = SpringUtil.getBean(RoleService.class) + .list(new LambdaQueryWrapper().eq(Role::getStatus, StatusEnum.ENABLE.getValue()) + .select(Role::getId, Role::getCode, Role::getName)); + this.deptList = SpringUtil.getBean(DeptService.class) + .list(new LambdaQueryWrapper().select(Dept::getId, Dept::getCode, Dept::getName)); + this.genderList = SpringUtil.getBean(DictItemService.class) + .list(new LambdaQueryWrapper().eq(DictItem::getDictCode, DictCodeEnum.GENDER.getValue())); + this.excelResult = new ExcelResult(); + } + + /** + * 每一条数据解析都会来调用 + *

+ * 1. 数据校验;全字段校验 + * 2. 数据持久化; + * + * @param userImportDto 一行数据,类似于 {@link AnalysisContext#readRowHolder()} + */ + @Override + public void invoke(UserImportForm userImportDto, AnalysisContext analysisContext) { + log.info("解析到一条用户数据:{}", JSONUtil.toJsonStr(userImportDto)); + + boolean validation = true; + String errorMsg = "第" + currentRow + "行数据校验失败:"; + String username = userImportDto.getUsername(); + if (StrUtil.isBlank(username)) { + errorMsg += "用户名为空;"; + validation = false; + } else { + long count = userService.count(new LambdaQueryWrapper().eq(SysUser::getUsername, username)); + if (count > 0) { + errorMsg += "用户名已存在;"; + validation = false; + } + } + + String nickname = userImportDto.getNickname(); + if (StrUtil.isBlank(nickname)) { + errorMsg += "用户昵称为空;"; + validation = false; + } + + String mobile = userImportDto.getMobile(); + if (StrUtil.isBlank(mobile)) { + errorMsg += "手机号码为空;"; + validation = false; + } else { + if (!Validator.isMobile(mobile)) { + errorMsg += "手机号码不正确;"; + validation = false; + } + } + + if (validation) { + // 校验通过,持久化至数据库 + SysUser entity = userConverter.toEntity(userImportDto); + entity.setPassword(passwordEncoder.encode(SystemConstants.DEFAULT_PASSWORD)); // 默认密码 + // 性别逆向翻译 根据字典标签得到字典值 + String genderLabel = userImportDto.getGenderLabel(); + entity.setGender(getGenderValue(genderLabel)); + // 角色解析 + String roleCodes = userImportDto.getRoleCodes(); + List roleIds = getRoleIds(roleCodes); + // 部门解析 + String deptCode = userImportDto.getDeptCode(); + entity.setDeptId(getDeptId(deptCode)); + + boolean saveResult = userService.save(entity); + if (saveResult) { + excelResult.setValidCount(excelResult.getValidCount() + 1); + // 保存用户角色关联 + if (CollectionUtil.isNotEmpty(roleIds)) { + List userRoles = roleIds.stream() + .map(roleId -> new UserRole(entity.getId(), roleId)) + .collect(Collectors.toList()); + userRoleService.saveBatch(userRoles); + } + } else { + excelResult.setInvalidCount(excelResult.getInvalidCount() + 1); + errorMsg += "第" + currentRow + "行数据保存失败;"; + excelResult.getMessageList().add(errorMsg); + } + } else { + excelResult.setInvalidCount(excelResult.getInvalidCount() + 1); + excelResult.getMessageList().add(errorMsg); + } + currentRow++; + } + + + /** + * 根据角色编码或名称获取角色ID + * + * @param roleCodes 角色编码或名称,逗号分隔 + * @return 角色ID集合 + */ + private List getRoleIds(String roleCodes) { + if (StrUtil.isNotBlank(roleCodes)) { + String[] split = roleCodes.split(","); + if (split.length > 0) { + List roleIds = new ArrayList<>(); + for (String roleCode : split) { + String trimmed = roleCode.trim(); + this.roleList.stream() + .filter(r -> r.getCode().equals(trimmed) || r.getName().equals(trimmed)) + .findFirst().ifPresent(role -> roleIds.add(role.getId())); + } + return roleIds.stream().distinct().toList(); + } + } + return Collections.emptyList(); + } + + /** + * 根据部门编码或名称获取部门ID + * + * @param deptCode 部门编码或名称 + * @return 部门ID + */ + private Long getDeptId(String deptCode) { + if (StrUtil.isNotBlank(deptCode)) { + String trimmed = deptCode.trim(); + return this.deptList.stream() + .filter(r -> r.getCode().equals(trimmed) || r.getName().equals(trimmed)) + .findFirst().map(Dept::getId).orElse(null); + } + return null; + } + + /** + * 根据性别标签获取性别值 + * + * @param genderLabel 性别标签 + * @return 性别值 + */ + private Integer getGenderValue(String genderLabel) { + if (StrUtil.isNotBlank(genderLabel)) { + return this.genderList.stream() + .filter(r -> r.getLabel().equals(genderLabel)) + .findFirst() + .map(DictItem::getValue) + .map(Convert::toInt) + .orElse(null); + } + return null; + } + + /** + * 所有数据解析完成会来调用 + */ + @Override + public void doAfterAllAnalysed(AnalysisContext analysisContext) { + log.info("所有数据解析完成!"); + } + +} diff --git a/src/main/java/com/youlai/boot/system/mapper/ConfigMapper.java b/src/main/java/com/youlai/boot/system/mapper/ConfigMapper.java new file mode 100644 index 0000000..d63997d --- /dev/null +++ b/src/main/java/com/youlai/boot/system/mapper/ConfigMapper.java @@ -0,0 +1,16 @@ +package com.youlai.boot.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.youlai.boot.system.model.entity.Config; +import org.apache.ibatis.annotations.Mapper; + +/** + * 系统配置 访问层 + * + * @author Theo + * @since 2024-7-29 11:41:04 + */ +@Mapper +public interface ConfigMapper extends BaseMapper { + +} diff --git a/src/main/java/com/youlai/boot/system/mapper/DeptMapper.java b/src/main/java/com/youlai/boot/system/mapper/DeptMapper.java new file mode 100644 index 0000000..e00f27b --- /dev/null +++ b/src/main/java/com/youlai/boot/system/mapper/DeptMapper.java @@ -0,0 +1,20 @@ +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.system.model.entity.Dept; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + + +@Mapper +public interface DeptMapper extends BaseMapper { + + @DataPermission(deptIdColumnName = "id") + @Override + List selectList(@Param(Constants.WRAPPER) Wrapper queryWrapper); +} diff --git a/src/main/java/com/youlai/boot/system/mapper/DictItemMapper.java b/src/main/java/com/youlai/boot/system/mapper/DictItemMapper.java new file mode 100644 index 0000000..64bb7af --- /dev/null +++ b/src/main/java/com/youlai/boot/system/mapper/DictItemMapper.java @@ -0,0 +1,27 @@ +package com.youlai.boot.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.system.model.entity.DictItem; +import com.youlai.boot.system.model.query.DictItemQuery; +import com.youlai.boot.system.model.vo.DictItemPageVO; +import org.apache.ibatis.annotations.Mapper; + +/** + * 字典项映射层 + * + * @author Ray Hao + * @since 2.9.0 + */ +@Mapper +public interface DictItemMapper extends BaseMapper { + + /** + * 字典项分页列表 + */ + Page getDictItemPage(Page page, DictItemQuery queryParams); +} + + + + diff --git a/src/main/java/com/youlai/boot/system/mapper/DictMapper.java b/src/main/java/com/youlai/boot/system/mapper/DictMapper.java new file mode 100644 index 0000000..c18b5d0 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/mapper/DictMapper.java @@ -0,0 +1,32 @@ +package com.youlai.boot.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.system.model.entity.Dict; +import com.youlai.boot.system.model.query.DictQuery; +import com.youlai.boot.system.model.vo.DictPageVO; +import org.apache.ibatis.annotations.Mapper; + +/** + * 字典 访问层 + * + * @author Ray Hao + * @since 2.9.0 + */ +@Mapper +public interface DictMapper extends BaseMapper { + + /** + * 字典分页列表 + * + * @param page 分页参数 + * @param queryParams 查询参数 + * @return 字典分页列表 + */ + Page getDictPage(Page page, DictQuery queryParams); + +} + + + + diff --git a/src/main/java/com/youlai/boot/system/mapper/LogMapper.java b/src/main/java/com/youlai/boot/system/mapper/LogMapper.java new file mode 100644 index 0000000..7f673e5 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/mapper/LogMapper.java @@ -0,0 +1,59 @@ +package com.youlai.boot.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.system.model.dto.VisitCountDTO; +import com.youlai.boot.system.model.vo.VisitOverviewVO; +import com.youlai.boot.system.model.entity.SysLog; +import com.youlai.boot.system.model.query.LogQuery; +import com.youlai.boot.system.model.vo.LogPageVO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + + +/** + * 系统日志数据访问层 + * + * @author Ray + * @since 2.10.0 + */ +@Mapper +public interface LogMapper extends BaseMapper { + + /** + * 获取日志分页列表 + */ + Page getLogPage(Page page, LogQuery queryParams); + + /** + * 统计浏览数(PV) + * + * @param startDate 开始日期 yyyy-MM-dd + * @param endDate 结束日期 yyyy-MM-dd + */ + List getPvCounts(String startDate, String endDate); + + /** + * 统计IP数 + * + * @param startDate 开始日期 yyyy-MM-dd + * @param endDate 结束日期 yyyy-MM-dd + */ + List getIpCounts(String startDate, String endDate); + + /** + * 获取浏览量(PV)统计 + */ + VisitOverviewVO getPvStats(); + + /** + * 获取访问IP统计 + */ + VisitOverviewVO getUvStats(); +} + + + + diff --git a/src/main/java/com/youlai/boot/system/mapper/MenuMapper.java b/src/main/java/com/youlai/boot/system/mapper/MenuMapper.java new file mode 100644 index 0000000..64b46bc --- /dev/null +++ b/src/main/java/com/youlai/boot/system/mapper/MenuMapper.java @@ -0,0 +1,27 @@ +package com.youlai.boot.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.youlai.boot.system.model.entity.Menu; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; +import java.util.Set; + +/** + * 菜单访问层 + * + * @author Ray + * @since 2022/1/24 + */ + +@Mapper +public interface MenuMapper extends BaseMapper

{ + + /** + * 获取菜单路由列表 + * + * @param roleCodes 角色编码集合 + */ + List getMenusByRoleCodes(Set roleCodes); + +} diff --git a/src/main/java/com/youlai/boot/system/mapper/NoticeMapper.java b/src/main/java/com/youlai/boot/system/mapper/NoticeMapper.java new file mode 100644 index 0000000..e26fa35 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/mapper/NoticeMapper.java @@ -0,0 +1,37 @@ +package com.youlai.boot.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.system.model.entity.Notice; +import com.youlai.boot.system.model.query.NoticeQuery; +import com.youlai.boot.system.model.vo.NoticePageVO; +import com.youlai.boot.system.model.vo.NoticeDetailVO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +/** + * 通知公告Mapper接口 + * + * @author youlaitech + * @since 2024-08-27 10:31 + */ +@Mapper +public interface NoticeMapper extends BaseMapper { + + /** + * 获取通知公告分页数据 + * + * @param page 分页对象 + * @param queryParams 查询参数 + * @return 通知公告分页数据 + */ + Page getNoticePage(Page page, NoticeQuery queryParams); + + /** + * 获取阅读时通知公告详情 + * + * @param id 通知公告ID + * @return 通知公告详情 + */ + NoticeDetailVO getNoticeDetail(@Param("id") Long id); +} diff --git a/src/main/java/com/youlai/boot/system/mapper/RoleDeptMapper.java b/src/main/java/com/youlai/boot/system/mapper/RoleDeptMapper.java new file mode 100644 index 0000000..86df3d6 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/mapper/RoleDeptMapper.java @@ -0,0 +1,35 @@ +package com.youlai.boot.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.youlai.boot.system.model.entity.RoleDept; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 角色部门关联持久层 + * + * @author Ray.Hao + * @since 3.0.0 + */ +@Mapper +public interface RoleDeptMapper extends BaseMapper { + + /** + * 根据角色ID获取部门ID列表 + * + * @param roleId 角色ID + * @return 部门ID列表 + */ + List getDeptIdsByRoleId(@Param("roleId") Long roleId); + + /** + * 根据角色编码集合获取所有部门ID列表(用于自定义数据权限) + * + * @param roleCodes 角色编码集合 + * @return 部门ID列表 + */ + List getDeptIdsByRoleCodes(@Param("roleCodes") List roleCodes); + +} diff --git a/src/main/java/com/youlai/boot/system/mapper/RoleMapper.java b/src/main/java/com/youlai/boot/system/mapper/RoleMapper.java new file mode 100644 index 0000000..fead039 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/mapper/RoleMapper.java @@ -0,0 +1,39 @@ +package com.youlai.boot.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.youlai.boot.system.model.entity.Role; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 角色持久层接口 + * + * @author Ray.Hao + * @since 2022/1/14 + */ +@Mapper +public interface RoleMapper extends BaseMapper { + + /** + * 获取最大范围的数据权限 + * + * @param roles 角色编码集合 + * @return {@link Integer} – 数据权限范围 + */ + Integer getMaximumDataScope(Set roles); + + /** + * 获取角色的数据权限信息列表 + *

+ * 返回角色编码和数据权限范围的映射列表 + * + * @param roleCodes 角色编码集合 + * @return 角色数据权限信息列表 [{code: 'ADMIN', data_scope: 1}, ...] + */ + List> getRoleDataScopeList(@Param("roleCodes") Set roleCodes); + +} diff --git a/src/main/java/com/youlai/boot/system/mapper/RoleMenuMapper.java b/src/main/java/com/youlai/boot/system/mapper/RoleMenuMapper.java new file mode 100644 index 0000000..bfe929a --- /dev/null +++ b/src/main/java/com/youlai/boot/system/mapper/RoleMenuMapper.java @@ -0,0 +1,41 @@ +package com.youlai.boot.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.youlai.boot.system.model.dto.RolePermsDTO; +import com.youlai.boot.system.model.entity.RoleMenu; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; +import java.util.Set; + +/** + * 角色菜单访问层 + * + * @author haoxr + * @since 2022/6/4 + */ +@Mapper +public interface RoleMenuMapper extends BaseMapper { + + /** + * 获取角色拥有的菜单ID集合 + * + * @param roleId 角色ID + * @return 菜单ID集合 + */ + List listMenuIdsByRoleId(Long roleId); + + /** + * 获取权限和拥有权限的角色列表 + */ + List getRolePermsList(String roleCode); + + + /** + * 获取角色权限集合 + * + * @param roles + * @return + */ + Set listRolePerms(Set roles); +} diff --git a/src/main/java/com/youlai/boot/system/mapper/UserMapper.java b/src/main/java/com/youlai/boot/system/mapper/UserMapper.java new file mode 100644 index 0000000..d07fdca --- /dev/null +++ b/src/main/java/com/youlai/boot/system/mapper/UserMapper.java @@ -0,0 +1,86 @@ +package com.youlai.boot.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +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.system.model.vo.UserExportVO; +import com.youlai.boot.system.model.vo.UserPageVO; +import com.youlai.boot.system.model.vo.UserProfileVO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 用户持久层接口 + * + * @author Ray.Hao + * @since 2022/1/14 + */ +@Mapper +public interface UserMapper extends BaseMapper { + + /** + * 获取用户分页列表 + * + * @param page 分页参数 + * @param queryParams 查询参数 + * @return 用户分页列表 + */ + @DataPermission(deptAlias = "u", userAlias = "u") + Page getUserPage(Page page, @Param("queryParams") UserQuery queryParams); + + /** + * 获取用户表单详情 + * + * @param userId 用户ID + * @return 用户表单详情 + */ + UserForm getUserFormData(Long userId); + + /** + * 根据用户名获取认证信息 + * + * @param username 用户名 + * @return 认证信息 + */ + UserAuthInfo getAuthInfoByUsername(String username); + + default UserAuthInfo getAuthCredentialsByUsername(String username) { + return getAuthInfoByUsername(username); + } + + /** + * 根据手机号获取用户认证信息 + * + * @param mobile 手机号 + * @return 认证信息 + */ + UserAuthInfo getAuthInfoByMobile(String mobile); + + default UserAuthInfo getAuthCredentialsByMobile(String mobile) { + return getAuthInfoByMobile(mobile); + } + + /** + * 获取导出用户列表 + * + * @param queryParams 查询参数 + * @return 导出用户列表 + */ + @DataPermission(deptAlias = "u", userAlias = "u") + List listExportUsers(UserQuery queryParams); + + /** + * 获取用户个人中心信息 + * + * @param userId 用户ID + * @return 用户个人中心信息 + */ + UserProfileVO getUserProfile(Long userId); + +} diff --git a/src/main/java/com/youlai/boot/system/mapper/UserNoticeMapper.java b/src/main/java/com/youlai/boot/system/mapper/UserNoticeMapper.java new file mode 100644 index 0000000..378f060 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/mapper/UserNoticeMapper.java @@ -0,0 +1,29 @@ +package com.youlai.boot.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.system.model.entity.UserNotice; +import com.youlai.boot.system.model.query.NoticeQuery; +import com.youlai.boot.system.model.vo.NoticePageVO; +import com.youlai.boot.system.model.vo.UserNoticePageVO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + + +/** + * 用户公告状态 + * + * @author youlaitech + * @since 2024-08-28 16:56 + */ +@Mapper +public interface UserNoticeMapper extends BaseMapper { + /** + * 分页获取我的通知公告 + * @param page 分页对象 + * @param queryParams 查询参数 + * @return 通知公告分页列表 + */ + IPage getMyNoticePage(Page page, @Param("queryParams") NoticeQuery queryParams); +} diff --git a/src/main/java/com/youlai/boot/system/mapper/UserRoleMapper.java b/src/main/java/com/youlai/boot/system/mapper/UserRoleMapper.java new file mode 100644 index 0000000..43383a1 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/mapper/UserRoleMapper.java @@ -0,0 +1,30 @@ +package com.youlai.boot.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.youlai.boot.system.model.entity.UserRole; +import org.apache.ibatis.annotations.Mapper; + +/** + * 用户角色访问层 + * + * @author haoxr + * @since 2022/1/15 + */ +@Mapper +public interface UserRoleMapper extends BaseMapper { + + /** + * 获取角色绑定的用户数 + * + * @param roleId 角色ID + */ + int countUsersByRoleId(Long roleId); + + /** + * 获取角色绑定的用户ID集合 + * + * @param roleId 角色ID + * @return 用户ID集合 + */ + java.util.List listUserIdsByRoleId(Long roleId); +} diff --git a/src/main/java/com/youlai/boot/system/mapper/UserSocialMapper.java b/src/main/java/com/youlai/boot/system/mapper/UserSocialMapper.java new file mode 100644 index 0000000..430075d --- /dev/null +++ b/src/main/java/com/youlai/boot/system/mapper/UserSocialMapper.java @@ -0,0 +1,22 @@ +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.system.model.entity.UserSocial; +import org.apache.ibatis.annotations.Mapper; + +/** + * 用户第三方账号绑定持久层 + */ +@Mapper +public interface UserSocialMapper extends BaseMapper { + + /** + * 根据用户ID获取认证信息 + * + * @param userId 用户ID + * @return 认证信息 + */ + UserAuthInfo getAuthInfoByUserId(Long userId); + +} diff --git a/src/main/java/com/youlai/boot/system/model/dto/RolePermsDTO.java b/src/main/java/com/youlai/boot/system/model/dto/RolePermsDTO.java new file mode 100644 index 0000000..68698de --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/dto/RolePermsDTO.java @@ -0,0 +1,24 @@ +package com.youlai.boot.system.model.dto; + +import lombok.Data; +import java.util.Set; + +/** + * 角色权限集合 + * + * @author Ray.Hao + * @since 3.0.0 + */ +@Data +public class RolePermsDTO { + + /** + * 角色编码 + */ + private String roleCode; + + /** + * 权限集合 + */ + private Set perms; +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/system/model/dto/VisitCountDTO.java b/src/main/java/com/youlai/boot/system/model/dto/VisitCountDTO.java new file mode 100644 index 0000000..f622de3 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/dto/VisitCountDTO.java @@ -0,0 +1,21 @@ +package com.youlai.boot.system.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 访问计数数据传输对象 + * + * @author Ray.Hao + * @since 2.10.0 + */ +@Schema(description = "访问计数数据传输对象") +@Data +public class VisitCountDTO { + + @Schema(description = "日期 yyyy-MM-dd") + private String date; + + @Schema(description = "访问次数") + private Integer count; +} \ No newline at end of file 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 new file mode 100644 index 0000000..da4b54c --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/entity/Config.java @@ -0,0 +1,56 @@ +package com.youlai.boot.system.model.entity; + +import com.baomidou.mybatisplus.annotation.*; +import com.youlai.boot.common.base.BaseEntity; +import lombok.Data; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.EqualsAndHashCode; + +/** + * 系统配置对象 + * + * @author Theo + * @since 2024-07-29 11:17:26 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@Schema(description = "系统配置") +@TableName("sys_config") +public class Config extends BaseEntity { + + /** + * 配置名称 + */ + private String configName; + + /** + * 配置键 + */ + private String configKey; + + /** + * 配置值 + */ + private String configValue; + + /** + * 描述、备注 + */ + private String remark; + + /** + * 创建人ID + */ + private Long createBy; + + /** + * 更新人ID + */ + private Long updateBy; + + /** + * 逻辑删除标识(0-未删除 1-已删除) + */ + private Integer isDeleted; + +} 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 new file mode 100644 index 0000000..3166d09 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/entity/Dept.java @@ -0,0 +1,66 @@ +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 lombok.Getter; +import lombok.Setter; + +/** + * 部门实体对象 + * + * @author Ray.Hao + * @since 2024/06/23 + */ +@TableName("sys_dept") +@Getter +@Setter +public class Dept extends BaseEntity { + + /** + * 部门名称 + */ + private String name; + + /** + * 部门编码 + */ + private String code; + + /** + * 父节点id + */ + private Long parentId; + + /** + * 父节点id路径 + */ + private String treePath; + + /** + * 显示顺序 + */ + private Integer sort; + + /** + * 状态(1-正常 0-禁用) + */ + private Integer status; + + /** + * 创建人 ID + */ + private Long createBy; + + /** + * 更新人 ID + */ + private Long updateBy; + + /** + * 是否删除(0-否 1-是) + */ + private Integer isDeleted; + +} \ No newline at end of file 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 new file mode 100644 index 0000000..d669ca5 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/entity/Dict.java @@ -0,0 +1,45 @@ +package com.youlai.boot.system.model.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.youlai.boot.common.base.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 字典实体 + * + * @author Ray.Hao + * @since 2022/12/17 + */ +@EqualsAndHashCode(callSuper = false) +@TableName("sys_dict") +@Data +public class Dict extends BaseEntity { + + /** + * 字典编码 + */ + private String dictCode; + + /** + * 字典名称 + */ + private String name; + + + /** + * 状态(1:启用, 0:停用) + */ + private Integer status; + + /** + * 备注 + */ + private String remark; + + /** + * 逻辑删除标识(0-未删除 1-已删除) + */ + private Integer isDeleted; + +} \ No newline at end of file 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 new file mode 100644 index 0000000..d7c45ce --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/entity/DictItem.java @@ -0,0 +1,63 @@ +package com.youlai.boot.system.model.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.youlai.boot.common.base.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 字典项实体对象 + * + * @author Ray.Hao + * @since 2022/12/17 + */ +@EqualsAndHashCode(callSuper = false) +@TableName("sys_dict_item") +@Data +public class DictItem extends BaseEntity { + + /** + * 字典编码 + */ + private String dictCode; + + /** + * 字典项名称 + */ + private String label; + + /** + * 字典项值 + */ + private String value; + + /** + * 排序 + */ + private Integer sort; + + /** + * 状态(1-正常,0-禁用) + */ + private Integer status; + + /** + * 备注 + */ + private String remark; + + /** + * 标签类型 + */ + private String tagType; + + /** + * 创建人 ID + */ + private Long createBy; + + /** + * 更新人 ID + */ + 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 new file mode 100644 index 0000000..475e966 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/entity/Menu.java @@ -0,0 +1,113 @@ +package com.youlai.boot.system.model.entity; + +import com.baomidou.mybatisplus.annotation.*; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * 菜单实体 + * + * @author Ray.Hao + * @since 2023/3/6 + */ +@TableName(value = "sys_menu", autoResultMap = true) +@Getter +@Setter +public class Menu { + /** + * 菜单ID + */ + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 父菜单ID + */ + private Long parentId; + + /** + * 菜单名称 + */ + private String name; + + /** + * 菜单类型(C-目录 M-菜单 B-按钮) + */ + private String type; + + /** + * 路由名称(Vue Router 中定义的路由名称) + */ + private String routeName; + + /** + * 路由路径(Vue Router 中定义的 URL 路径) + */ + private String routePath; + + /** + * 组件路径(vue页面完整路径,省略.vue后缀) + */ + private String component; + + /** + * 权限标识 + */ + private String perm; + + /** + * 显示状态(1:显示;0:隐藏) + */ + private Integer visible; + + /** + * 排序 + */ + private Integer sort; + + /** + * 菜单图标 + */ + private String icon; + + /** + * 跳转路径 + */ + private String redirect; + + /** + * 父节点路径,以英文逗号(,)分割 + */ + private String treePath; + + /** + * 【菜单】是否开启页面缓存(1:开启;0:关闭) + */ + private Integer keepAlive; + + /** + * 【目录】只有一个子路由是否始终显示(1:是 0:否) + */ + private Integer alwaysShow; + + /** + * 路由参数 + */ + @TableField(updateStrategy = FieldStrategy.ALWAYS, typeHandler = JacksonTypeHandler.class) + private Map params; + + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + +} 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 new file mode 100644 index 0000000..e16c1d6 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/entity/Notice.java @@ -0,0 +1,89 @@ +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 lombok.Getter; +import lombok.Setter; + +import java.io.Serial; +import java.time.LocalDateTime; + +/** + * 通知公告实体对象 + * + * @author Kylin + * @since 2024-08-27 10:31 + */ +@Getter +@Setter +@TableName("sys_notice") +public class Notice extends BaseEntity { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 通知标题 + */ + private String title; + /** + * 通知内容 + */ + private String content; + /** + * 通知类型 + */ + private Integer type; + + /** + * 发布人 + */ + private Long publisherId; + + /** + * 通知等级(L: 低, M: 中, H: 高) + */ + private String level; + + /** + * 目标类型(1: 全体, 2: 指定) + */ + private Integer targetType; + + /** + * 目标用户ID集合 + */ + private String targetUserIds; + + /** + * 发布状态(0: 未发布, 1: 已发布, -1: 已撤回) + */ + private Integer publishStatus; + + /** + * 发布时间 + */ + private LocalDateTime publishTime; + + /** + * 撤回时间 + */ + private LocalDateTime revokeTime; + + /** + * 创建人ID + */ + private Long createBy; + + /** + * 更新人ID + */ + private Long updateBy; + + /** + * 逻辑删除标识(0-未删除 1-已删除) + */ + private Integer isDeleted; +} 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 new file mode 100644 index 0000000..5e39fdf --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/entity/Role.java @@ -0,0 +1,60 @@ +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 lombok.Getter; +import lombok.Setter; + +/** + * 角色实体 + * + * @author Ray.Hao + * @since 2024/6/23 + */ +@TableName("sys_role") +@Getter +@Setter +public class Role extends BaseEntity { + + /** + * 角色名称 + */ + private String name; + + /** + * 角色编码 + */ + private String code; + + /** + * 显示顺序 + */ + private Integer sort; + + /** + * 角色状态(1-正常 0-停用) + */ + private Integer status; + + /** + * 数据权限 + */ + private Integer dataScope; + + /** + * 创建人 ID + */ + private Long createBy; + + /** + * 更新人 ID + */ + private Long updateBy; + + /** + * 是否删除(0-否 1-是) + */ + private Integer isDeleted; +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/system/model/entity/RoleDept.java b/src/main/java/com/youlai/boot/system/model/entity/RoleDept.java new file mode 100644 index 0000000..93ef111 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/entity/RoleDept.java @@ -0,0 +1,32 @@ +package com.youlai.boot.system.model.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 角色部门关联实体 + *

+ * 用于存储角色自定义数据权限时,可访问的部门ID列表 + * + * @author Ray.Hao + * @since 3.0.0 + */ +@TableName("sys_role_dept") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class RoleDept { + + /** + * 角色ID + */ + private Long roleId; + + /** + * 部门ID + */ + private Long deptId; + +} 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 new file mode 100644 index 0000000..3810969 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/entity/RoleMenu.java @@ -0,0 +1,28 @@ +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; + +/** + * 角色和菜单关联表 + */ +@TableName("sys_role_menu") +@Data +@AllArgsConstructor +@NoArgsConstructor +public class RoleMenu { + /** + * 角色ID + */ + private Long roleId; + + /** + * 菜单ID + */ + private Long menuId; + +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/system/model/entity/SysLog.java b/src/main/java/com/youlai/boot/system/model/entity/SysLog.java new file mode 100644 index 0000000..ae93c98 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/entity/SysLog.java @@ -0,0 +1,120 @@ +package com.youlai.boot.system.model.entity; + +import com.baomidou.mybatisplus.annotation.*; +import com.youlai.boot.common.enums.ActionTypeEnum; +import com.youlai.boot.common.enums.LogModuleEnum; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 系统日志 实体类 + * + * @author Ray.Hao + * @since 2.10.0 + */ +@Data +@TableName("sys_log") +public class SysLog implements Serializable { + + /** + * 主键 + */ + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 模块 + */ + private LogModuleEnum module; + + /** + * 操作类型 + */ + @TableField(value = "action_type") + private ActionTypeEnum actionType; + + /** + * 操作标题 + */ + private String title; + + /** + * 自定义日志内容 + */ + private String content; + + /** + * 操作人ID + */ + private Long operatorId; + + /** + * 操作人名称 + */ + private String operatorName; + + /** + * 请求路径 + */ + private String requestUri; + + /** + * 请求方式 + */ + @TableField(value = "request_method") + private String requestMethod; + + /** + * IP 地址 + */ + private String ip; + + /** + * 省份 + */ + private String province; + + /** + * 城市 + */ + private String city; + + /** + * 设备 + */ + private String device; + + /** + * 操作系统 + */ + private String os; + + /** + * 浏览器 + */ + private String browser; + + /** + * 状态:0失败 1成功 + */ + private Integer status; + + /** + * 错误信息 + */ + @TableField(value = "error_msg") + private String errorMsg; + + /** + * 执行时间(毫秒) + */ + private Integer executionTime; + + /** + * 操作时间 + */ + private LocalDateTime createTime; + +} 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 new file mode 100644 index 0000000..92cc954 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/entity/SysUser.java @@ -0,0 +1,75 @@ +package com.youlai.boot.system.model.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.youlai.boot.common.base.BaseEntity; +import lombok.Getter; +import lombok.Setter; + +/** + * 用户实体 + */ +@TableName("sys_user") +@Getter +@Setter +public class SysUser extends BaseEntity { + + /** + * 用户名 + */ + private String username; + + /** + * 昵称 + */ + private String nickname; + + /** + * 性别((1-男 2-女 0-保密) + */ + private Integer gender; + + /** + * 密码 + */ + private String password; + + /** + * 部门ID + */ + private Long deptId; + + /** + * 用户头像 + */ + private String avatar; + + /** + * 联系方式 + */ + private String mobile; + + /** + * 状态((1-正常 0-禁用) + */ + private Integer status; + + /** + * 用户邮箱 + */ + private String email; + + /** + * 创建人 ID + */ + private Long createBy; + + /** + * 更新人 ID + */ + private Long updateBy; + + /** + * 是否删除(0-否 1-是) + */ + private Integer isDeleted; +} \ No newline at end of file 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 new file mode 100644 index 0000000..28675f5 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/entity/UserNotice.java @@ -0,0 +1,54 @@ +package com.youlai.boot.system.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +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 lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** + * 用户通知公告实体对象 + * + * @author Kylin + * @since 2024-08-28 16:56 + */ +@Getter +@Setter +@TableName("sys_user_notice") +public class UserNotice extends BaseEntity { + + /** + * 主键ID + */ + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 公共通知id + */ + private Long noticeId; + /** + * 用户id + */ + private Long userId; + /** + * 读取状态,0未读,1已读 + */ + private Integer isRead; + /** + * 用户阅读时间 + */ + private LocalDateTime readTime; + + /** + * 逻辑删除标识(0-未删除 1-已删除) + */ + @TableLogic(value = "0", delval = "1") + private Integer isDeleted; +} diff --git a/src/main/java/com/youlai/boot/system/model/entity/UserRole.java b/src/main/java/com/youlai/boot/system/model/entity/UserRole.java new file mode 100644 index 0000000..9ab2e05 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/entity/UserRole.java @@ -0,0 +1,29 @@ +package com.youlai.boot.system.model.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + + +/** + * 用户和角色关联表 + * + * @author Rya.Hao + * @since 2022/12/17 + */ +@TableName("sys_user_role") +@Data +@AllArgsConstructor +@NoArgsConstructor +public class UserRole { + /** + * 用户ID + */ + private Long userId; + + /** + * 角色ID + */ + private Long roleId; +} \ No newline at end of file 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 new file mode 100644 index 0000000..f8ddff8 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/entity/UserSocial.java @@ -0,0 +1,74 @@ +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 lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** + * 用户第三方账号绑定实体 + */ +@TableName("sys_user_social") +@Getter +@Setter +public class UserSocial { + + /** + * 主键ID + */ + private Long id; + + /** + * 用户ID + */ + private Long userId; + + /** + * 平台类型 + */ + private SocialPlatformEnum platform; + + /** + * 平台openid + */ + private String openid; + + /** + * 微信unionid + */ + private String unionid; + + /** + * 第三方昵称 + */ + private String nickname; + + /** + * 第三方头像URL + */ + private String avatar; + + /** + * 微信session_key + */ + private String sessionKey; + + /** + * 是否已验证(1-已验证 0-未验证) + */ + private Integer verified; + + /** + * 绑定时间 + */ + private LocalDateTime createTime; + + /** + * 更新时间 + */ + private LocalDateTime updateTime; + +} diff --git a/src/main/java/com/youlai/boot/system/model/form/ConfigForm.java b/src/main/java/com/youlai/boot/system/model/form/ConfigForm.java new file mode 100644 index 0000000..6e83c03 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/form/ConfigForm.java @@ -0,0 +1,41 @@ +package com.youlai.boot.system.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 系统配置 表单实体 + * + * @author Theo + * @since 2024-07-29 11:17:26 + */ +@Data +@Schema(description = "系统配置Form实体") +public class ConfigForm implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "主键") + private Long id; + + @NotBlank(message = "配置名称不能为空") + @Schema(description = "配置名称") + private String configName; + + @NotBlank(message = "配置键不能为空") + @Schema(description = "配置键") + private String configKey; + + @NotBlank(message = "配置值不能为空") + @Schema(description = "配置值") + private String configValue; + + @Schema(description = "描述、备注") + private String remark; +} diff --git a/src/main/java/com/youlai/boot/system/model/form/DeptForm.java b/src/main/java/com/youlai/boot/system/model/form/DeptForm.java new file mode 100644 index 0000000..66cc365 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/form/DeptForm.java @@ -0,0 +1,34 @@ +package com.youlai.boot.system.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.validator.constraints.Range; + +@Schema(description = "部门表单对象") +@Getter +@Setter +public class DeptForm { + + @Schema(description="部门ID", example = "1001") + private Long id; + + @Schema(description="部门名称", example = "研发部") + private String name; + + @Schema(description="部门编号", example = "RD001") + private String code; + + @Schema(description="父部门ID", example = "1000") + @NotNull(message = "父部门ID不能为空") + private Long parentId; + + @Schema(description="状态(1:启用;0:禁用)", example = "1") + @Range(min = 0, max = 1, message = "状态值不正确") + private Integer status; + + @Schema(description="排序(数字越小排名越靠前)", example = "1") + private Integer sort; + +} diff --git a/src/main/java/com/youlai/boot/system/model/form/DictForm.java b/src/main/java/com/youlai/boot/system/model/form/DictForm.java new file mode 100644 index 0000000..b17d320 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/form/DictForm.java @@ -0,0 +1,40 @@ +package com.youlai.boot.system.model.form; + + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.validator.constraints.Range; + +import java.util.List; + +/** + * 字典表单对象 + * + * @author Ray Hao + * @since 2.9.0 + */ +@Schema(description = "字典") +@Data +public class DictForm { + + @Schema(description = "字典ID",example = "1") + private Long id; + + @Schema(description = "字典名称",example = "性别") + private String name; + + @Schema(description = "字典编码", example ="gender") + @NotBlank(message = "字典编码不能为空") + private String dictCode; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "字典状态(1-启用,0-禁用)", example = "1") + @Range(min = 0, max = 1, message = "字典状态不正确") + private Integer status; + +} diff --git a/src/main/java/com/youlai/boot/system/model/form/DictItemForm.java b/src/main/java/com/youlai/boot/system/model/form/DictItemForm.java new file mode 100644 index 0000000..373a521 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/form/DictItemForm.java @@ -0,0 +1,38 @@ +package com.youlai.boot.system.model.form; + + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 字典项表单对象 + * + * @author Ray Hao + * @since 2.9.0 + */ +@Schema(description = "字典项表单") +@Data +public class DictItemForm { + + @Schema(description = "字典项ID") + private Long id; + + @Schema(description = "字典编码") + private String dictCode; + + @Schema(description = "字典项值") + private String value; + + @Schema(description = "字典项标签") + private String label; + + @Schema(description = "排序") + private Integer sort; + + @Schema(description = "状态(0:禁用,1:启用)") + private Integer status; + + @Schema(description = "字典类型(用于显示样式)") + private String tagType; + +} diff --git a/src/main/java/com/youlai/boot/system/model/form/EmailUpdateForm.java b/src/main/java/com/youlai/boot/system/model/form/EmailUpdateForm.java new file mode 100644 index 0000000..6249531 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/form/EmailUpdateForm.java @@ -0,0 +1,31 @@ +package com.youlai.boot.system.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 修改邮箱表单 + * + * @author Ray.Hao + * @since 2024/8/19 + */ +@Schema(description = "修改邮箱表单") +@Data +public class EmailUpdateForm { + + @Schema(description = "邮箱") + @NotBlank(message = "邮箱不能为空") + @Email(message = "邮箱格式不正确") + private String email; + + @Schema(description = "验证码") + @NotBlank(message = "验证码不能为空") + private String code; + + @Schema(description = "当前密码") + @NotBlank(message = "当前密码不能为空") + private String password; + +} diff --git a/src/main/java/com/youlai/boot/system/model/form/MenuForm.java b/src/main/java/com/youlai/boot/system/model/form/MenuForm.java new file mode 100644 index 0000000..dabc73c --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/form/MenuForm.java @@ -0,0 +1,66 @@ +package com.youlai.boot.system.model.form; + +import com.youlai.boot.common.model.KeyValue; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.hibernate.validator.constraints.Range; + +import java.util.List; + +/** + * 菜单表单对象 + * + * @author Ray.Hao + * @since 2024/06/23 + */ +@Schema(description = "菜单表单对象") +@Data +public class MenuForm { + + @Schema(description = "菜单ID") + private Long id; + + @Schema(description = "父菜单ID") + private Long parentId; + + @Schema(description = "菜单名称") + private String name; + + @Schema(description = "菜单类型(C-目录 M-菜单 B-按钮)") + private String type; + + @Schema(description = "路由名称") + private String routeName; + + @Schema(description = "路由路径") + private String routePath; + + @Schema(description = "组件路径(vue页面完整路径,省略.vue后缀)") + private String component; + + @Schema(description = "权限标识") + private String perm; + + @Schema(description = "显示状态(1:显示;0:隐藏)") + @Range(max = 1, min = 0, message = "显示状态不正确") + private Integer visible; + + @Schema(description = "排序(数字越小排名越靠前)") + private Integer sort; + + @Schema(description = "菜单图标") + private String icon; + + @Schema(description = "跳转路径") + private String redirect; + + @Schema(description = "【菜单】是否开启页面缓存", example = "1") + private Integer keepAlive; + + @Schema(description = "【目录】只有一个子路由是否始终显示", example = "1") + private Integer alwaysShow; + + @Schema(description = "路由参数") + private List params; + +} diff --git a/src/main/java/com/youlai/boot/system/model/form/MobileUpdateForm.java b/src/main/java/com/youlai/boot/system/model/form/MobileUpdateForm.java new file mode 100644 index 0000000..be62079 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/form/MobileUpdateForm.java @@ -0,0 +1,31 @@ +package com.youlai.boot.system.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Data; + +/** + * 修改手机表单 + * + * @author Ray.Hao + * @since 2024/8/19 + */ +@Schema(description = "修改手机表单") +@Data +public class MobileUpdateForm { + + @Schema(description = "手机号码") + @NotBlank(message = "手机号码不能为空") + @Pattern(regexp = "^1(3\\d|4[5-9]|5[0-35-9]|6[2567]|7[0-8]|8\\d|9[0-35-9])\\d{8}$", message = "手机号码格式不正确") + private String mobile; + + @Schema(description = "验证码") + @NotBlank(message = "验证码不能为空") + private String code; + + @Schema(description = "当前密码") + @NotBlank(message = "当前密码不能为空") + private String password; + +} diff --git a/src/main/java/com/youlai/boot/system/model/form/NoticeForm.java b/src/main/java/com/youlai/boot/system/model/form/NoticeForm.java new file mode 100644 index 0000000..f5de6dc --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/form/NoticeForm.java @@ -0,0 +1,54 @@ +package com.youlai.boot.system.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.validator.constraints.Range; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 通知公告表单对象 + * + * @author youlaitech + * @since 2024-08-27 10:31 + */ +@Getter +@Setter +@Schema(description = "通知公告表单对象") +public class NoticeForm implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "通知ID") + private Long id; + + @Schema(description = "通知标题") + @NotBlank(message = "通知标题不能为空") + @Size(max=50, message="通知标题长度不能超过50个字符") + private String title; + + @Schema(description = "通知内容") + @NotBlank(message = "通知内容不能为空") + @Size(max=65535, message="通知内容长度不能超过65535个字符") + private String content; + + @Schema(description = "通知类型") + private Integer type; + + @Schema(description = "优先级(L-低 M-中 H-高)") + private String level; + + @Schema(description = "目标类型(1-全体 2-指定)") + @Range(min = 1, max = 2, message = "目标类型取值范围[1,2]") + private Integer targetType; + + @Schema(description = "接收人ID集合") + private List targetUserIds; + +} diff --git a/src/main/java/com/youlai/boot/system/model/form/PasswordUpdateForm.java b/src/main/java/com/youlai/boot/system/model/form/PasswordUpdateForm.java new file mode 100644 index 0000000..aaab86d --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/form/PasswordUpdateForm.java @@ -0,0 +1,24 @@ +package com.youlai.boot.system.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 修改密码表单 + * + * @author Ray.Hao + * @since 2024/8/13 + */ +@Schema(description = "修改密码表单") +@Data +public class PasswordUpdateForm { + + @Schema(description = "原密码") + private String oldPassword; + + @Schema(description = "新密码") + private String newPassword; + + @Schema(description = "确认密码") + private String confirmPassword; +} diff --git a/src/main/java/com/youlai/boot/system/model/form/PasswordVerifyForm.java b/src/main/java/com/youlai/boot/system/model/form/PasswordVerifyForm.java new file mode 100644 index 0000000..674043f --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/form/PasswordVerifyForm.java @@ -0,0 +1,14 @@ +package com.youlai.boot.system.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Schema(description = "密码校验表单") +@Data +public class PasswordVerifyForm { + + @Schema(description = "当前密码") + @NotBlank(message = "当前密码不能为空") + private String password; +} diff --git a/src/main/java/com/youlai/boot/system/model/form/RoleForm.java b/src/main/java/com/youlai/boot/system/model/form/RoleForm.java new file mode 100644 index 0000000..3f0e09d --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/form/RoleForm.java @@ -0,0 +1,40 @@ +package com.youlai.boot.system.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +// import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotBlank; +import org.hibernate.validator.constraints.Range; + +import java.util.List; + +@Schema(description = "角色表单对象") +@Data +public class RoleForm { + + @Schema(description="角色ID") + private Long id; + + @Schema(description="角色名称") + @NotBlank(message = "角色名称不能为空") + private String name; + + @Schema(description="角色编码") + @NotBlank(message = "角色编码不能为空") + private String code; + + @Schema(description="排序") + private Integer sort; + + @Schema(description="角色状态(1-正常;0-停用)") + @Range(max = 1, min = 0, message = "角色状态不正确") + private Integer status; + + @Schema(description="数据权限(1-所有数据 2-部门及子部门数据 3-本部门数据 4-本人数据 5-自定义部门数据)") + private Integer dataScope; + + @Schema(description="自定义数据权限部门ID列表(当dataScope=5时有效)") + private List deptIds; + +} diff --git a/src/main/java/com/youlai/boot/system/model/form/UserForm.java b/src/main/java/com/youlai/boot/system/model/form/UserForm.java new file mode 100644 index 0000000..8b48259 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/form/UserForm.java @@ -0,0 +1,59 @@ +package com.youlai.boot.system.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Pattern; +import org.hibernate.validator.constraints.Range; + +import java.util.List; + +/** + * 用户表单对象 + * + * @author haoxr + * @since 2022/4/12 11:04 + */ +@Schema(description = "用户表单对象") +@Data +public class UserForm { + + @Schema(description="用户ID") + private Long id; + + @Schema(description="用户名") + @NotBlank(message = "用户名不能为空") + private String username; + + @Schema(description="昵称") + @NotBlank(message = "昵称不能为空") + private String nickname; + + + @Schema(description="手机号码") + @Pattern(regexp = "^$|^1(3\\d|4[5-9]|5[0-35-9]|6[2567]|7[0-8]|8\\d|9[0-35-9])\\d{8}$", message = "手机号码格式不正确") + private String mobile; + + @Schema(description="性别") + private Integer gender; + + @Schema(description="用户头像") + private String avatar; + + @Schema(description="邮箱") + private String email; + + @Schema(description="用户状态(1:正常;0:禁用)") + @Range(min = 0, max = 1, message = "用户状态不正确") + private Integer status; + + @Schema(description="部门ID") + private Long deptId; + + @Schema(description="角色ID集合") + @NotEmpty(message = "用户角色不能为空") + private List roleIds; + +} diff --git a/src/main/java/com/youlai/boot/system/model/form/UserImportForm.java b/src/main/java/com/youlai/boot/system/model/form/UserImportForm.java new file mode 100644 index 0000000..a5945d8 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/form/UserImportForm.java @@ -0,0 +1,36 @@ +package com.youlai.boot.system.model.form; + +import cn.idev.excel.annotation.ExcelProperty; +import lombok.Data; + +/** + * 用户导入表单 + * + * @author Ray.Hao + * @since 2022/4/10 + */ +@Data +public class UserImportForm { + + @ExcelProperty(value = "用户名") + private String username; + + @ExcelProperty(value = "昵称") + private String nickname; + + @ExcelProperty(value = "性别") + private String genderLabel; + + @ExcelProperty(value = "手机号码") + private String mobile; + + @ExcelProperty(value = "邮箱") + private String email; + + @ExcelProperty("角色") + private String roleCodes; + + @ExcelProperty("部门") + private String deptCode; + +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/system/model/form/UserProfileForm.java b/src/main/java/com/youlai/boot/system/model/form/UserProfileForm.java new file mode 100644 index 0000000..2595334 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/form/UserProfileForm.java @@ -0,0 +1,24 @@ +package com.youlai.boot.system.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 个人中心用户信息 + * + * @author Ray.Hao + * @since 2024/8/13 + */ +@Schema(description = "个人中心用户信息") +@Data +public class UserProfileForm { + @Schema(description = "用户昵称") + private String nickname; + + @Schema(description = "头像URL") + private String avatar; + + @Schema(description = "性别") + private Integer gender; + +} diff --git a/src/main/java/com/youlai/boot/system/model/query/ConfigQuery.java b/src/main/java/com/youlai/boot/system/model/query/ConfigQuery.java new file mode 100644 index 0000000..88ce9d8 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/query/ConfigQuery.java @@ -0,0 +1,21 @@ +package com.youlai.boot.system.model.query; + +import com.youlai.boot.common.base.BaseQuery; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +/** + * 系统配置查询对象 + * + * @author Theo + * @since 2024-7-29 11:38:00 + */ +@Getter +@Setter +@Schema(description = "系统配置查询") +public class ConfigQuery extends BaseQuery { + + @Schema(description="关键字(配置项名称/配置项值)") + private String keywords; +} diff --git a/src/main/java/com/youlai/boot/system/model/query/DeptQuery.java b/src/main/java/com/youlai/boot/system/model/query/DeptQuery.java new file mode 100644 index 0000000..3a7afad --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/query/DeptQuery.java @@ -0,0 +1,22 @@ +package com.youlai.boot.system.model.query; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 部门查询对象 + * + * @author haoxr + * @since 2022/6/11 + */ +@Schema(description ="部门分页查询对象") +@Data +public class DeptQuery { + + @Schema(description="关键字(部门名称)") + private String keywords; + + @Schema(description="状态(1->正常;0->禁用)") + private Integer status; + +} diff --git a/src/main/java/com/youlai/boot/system/model/query/DictItemQuery.java b/src/main/java/com/youlai/boot/system/model/query/DictItemQuery.java new file mode 100644 index 0000000..f7d1ff6 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/query/DictItemQuery.java @@ -0,0 +1,19 @@ +package com.youlai.boot.system.model.query; + +import com.youlai.boot.common.base.BaseQuery; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = false) +@Schema(description ="字典项查询对象") +public class DictItemQuery extends BaseQuery { + + @Schema(description="关键字(字典项值/字典项名称)") + private String keywords; + + @Schema(description="字典编码") + private String dictCode; + +} diff --git a/src/main/java/com/youlai/boot/system/model/query/DictQuery.java b/src/main/java/com/youlai/boot/system/model/query/DictQuery.java new file mode 100644 index 0000000..184065f --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/query/DictQuery.java @@ -0,0 +1,16 @@ +package com.youlai.boot.system.model.query; + +import com.youlai.boot.common.base.BaseQuery; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = false) +@Schema(description ="字典查询对象") +public class DictQuery extends BaseQuery { + + @Schema(description="关键字(字典名称)") + private String keywords; + +} diff --git a/src/main/java/com/youlai/boot/system/model/query/LogQuery.java b/src/main/java/com/youlai/boot/system/model/query/LogQuery.java new file mode 100644 index 0000000..dbc8c2d --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/query/LogQuery.java @@ -0,0 +1,21 @@ +package com.youlai.boot.system.model.query; + +import com.youlai.boot.common.base.BaseQuery; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Schema(description = "日志查询对象") +@Getter +@Setter +public class LogQuery extends BaseQuery { + + @Schema(description="关键字(IP/操作人)") + private String keywords; + + @Schema(description="操作时间范围") + List createTime; + +} diff --git a/src/main/java/com/youlai/boot/system/model/query/MenuQuery.java b/src/main/java/com/youlai/boot/system/model/query/MenuQuery.java new file mode 100644 index 0000000..9497b29 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/query/MenuQuery.java @@ -0,0 +1,22 @@ +package com.youlai.boot.system.model.query; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 菜单查询对象 + * + * @author haoxr + * @since 2022/10/28 + */ +@Schema(description ="菜单查询对象") +@Data +public class MenuQuery { + + @Schema(description="关键字(菜单名称)") + private String keywords; + + @Schema(description="状态(1->显示;0->隐藏)") + private Integer status; + +} diff --git a/src/main/java/com/youlai/boot/system/model/query/NoticeQuery.java b/src/main/java/com/youlai/boot/system/model/query/NoticeQuery.java new file mode 100644 index 0000000..55154a4 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/query/NoticeQuery.java @@ -0,0 +1,36 @@ +package com.youlai.boot.system.model.query; + +import com.youlai.boot.common.base.BaseQuery; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +/** + * 通知公告查询对象 + * + * @author youlaitech + * @since 2024-08-27 10:31 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@Schema(description ="通知公告查询对象") +public class NoticeQuery extends BaseQuery { + + @Schema(description = "通知标题") + private String title; + + @Schema(description = "发布状态(0-未发布 1已发布 -1已撤回)") + private Integer publishStatus; + + @Schema(description = "发布时间(起止)") + private List publishTime; + + @Schema(description = "查询人ID") + private Long userId; + + @Schema(description = "是否已读(0-未读 1-已读)") + private Integer isRead; + +} diff --git a/src/main/java/com/youlai/boot/system/model/query/RoleQuery.java b/src/main/java/com/youlai/boot/system/model/query/RoleQuery.java new file mode 100644 index 0000000..f69e4af --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/query/RoleQuery.java @@ -0,0 +1,32 @@ +package com.youlai.boot.system.model.query; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.youlai.boot.common.base.BaseQuery; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** + * 角色查询对象 + * + * @author Ray + * @since 2022/6/3 + */ +@Schema(description = "角色查询对象") +@Getter +@Setter +public class RoleQuery extends BaseQuery { + + @Schema(description="关键字(角色名称/角色编码)") + private String keywords; + + @Schema(description="开始日期") + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDateTime startDate; + + @Schema(description="结束日期") + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDateTime endDate; +} diff --git a/src/main/java/com/youlai/boot/system/model/query/UserQuery.java b/src/main/java/com/youlai/boot/system/model/query/UserQuery.java new file mode 100644 index 0000000..43bdeed --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/query/UserQuery.java @@ -0,0 +1,34 @@ +package com.youlai.boot.system.model.query; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.youlai.boot.common.base.BaseQuery; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +@Data +@EqualsAndHashCode(callSuper = false) +@Schema(description = "用户查询对象") +public class UserQuery extends BaseQuery { + + @Schema(description = "关键字(用户名/昵称/手机号)") + private String keywords; + + @Schema(description = "用户状态") + private Integer status; + + @Schema(description = "部门ID") + private Long deptId; + + @Schema(description = "角色ID") + private List roleIds; + + @Schema(description = "创建时间范围") + private List createTime; + + @JsonIgnore + @Schema(hidden = true) + private Boolean isRoot; +} diff --git a/src/main/java/com/youlai/boot/system/model/vo/ConfigVO.java b/src/main/java/com/youlai/boot/system/model/vo/ConfigVO.java new file mode 100644 index 0000000..7cd2aea --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/vo/ConfigVO.java @@ -0,0 +1,40 @@ +package com.youlai.boot.system.model.vo; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import java.io.Serial; +import java.io.Serializable; + +/** + * 系统配置视图对象 + * + * @author Theo + * @since 2024-07-30 14:49 + */ +@Data +@Builder +@EqualsAndHashCode(callSuper = false) +@Schema(description = "系统配置Vo") +public class ConfigVO { + + @Schema(description = "主键") + private Long id; + + @Schema(description = "配置名称") + private String configName; + + @Schema(description = "配置键") + private String configKey; + + @Schema(description = "配置值") + private String configValue; + + @Schema(description = "描述、备注") + private String remark; +} + + \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/system/model/vo/CurrentUserVO.java b/src/main/java/com/youlai/boot/system/model/vo/CurrentUserVO.java new file mode 100644 index 0000000..67f3406 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/vo/CurrentUserVO.java @@ -0,0 +1,45 @@ +package com.youlai.boot.system.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.Set; + +/** + * 当前登录用户视图对象 + * + * @author Ray.Hao + * @since 2022/1/14 + */ +@Schema(description ="当前登录用户视图对象") +@Data +public class CurrentUserVO { + + @Schema(description="用户ID") + private Long userId; + + @Schema(description="用户名") + private String username; + + @Schema(description="用户昵称") + private String nickname; + + @Schema(description="头像地址") + private String avatar; + + @Schema(description = "性别(1-男 2-女 0-保密)") + private Integer gender; + + @Schema(description = "部门名称") + private String deptName; + + @Schema(description="用户角色编码集合") + private Set roles; + + @Schema(description = "用户角色名称集合") + private Set roleNames; + + @Schema(description="用户权限标识集合") + private Set perms; + +} \ No newline at end of file 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 new file mode 100644 index 0000000..6cded32 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/vo/DeptVO.java @@ -0,0 +1,45 @@ +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 java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "部门视图对象") +@Data +public class DeptVO { + + @Schema(description = "部门ID") + private Long id; + + @Schema(description = "父部门ID") + private Long parentId; + + @Schema(description = "部门名称") + private String name; + + @Schema(description = "部门编号") + private String code; + + @Schema(description = "排序") + private Integer sort; + + @Schema(description = "状态(1:启用;0:禁用)") + private Integer status; + + @Schema(description = "子部门") + private List children; + + @Schema(description = "创建时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm") + private LocalDateTime createTime; + @Schema(description = "修改时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm") + private LocalDateTime updateTime; + +} + + + diff --git a/src/main/java/com/youlai/boot/system/model/vo/DictItemOptionVO.java b/src/main/java/com/youlai/boot/system/model/vo/DictItemOptionVO.java new file mode 100644 index 0000000..3fc2742 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/vo/DictItemOptionVO.java @@ -0,0 +1,29 @@ +package com.youlai.boot.system.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +/** + * 字典项键值对象 + * + * @author Ray.Hao + * @since 0.0.1 + */ +@Schema(description = "字典项键值对象") +@Getter +@Setter +public class DictItemOptionVO { + + @Schema(description = "字典项值") + private String value; + + @Schema(description = "字典项标签") + private String label; + + @Schema(description = "标签类型") + private String tagType; + +} + + \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/system/model/vo/DictItemPageVO.java b/src/main/java/com/youlai/boot/system/model/vo/DictItemPageVO.java new file mode 100644 index 0000000..53a1b8d --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/vo/DictItemPageVO.java @@ -0,0 +1,39 @@ +package com.youlai.boot.system.model.vo; + + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +/** + * 字典项分页对象 + * + * @author Ray.Hao + * @since 0.0.1 + */ +@Schema(description = "字典项分页对象") +@Getter +@Setter +public class DictItemPageVO { + + @Schema(description = "字典项ID") + private Long id; + + @Schema(description = "字典编码") + private String dictCode; + + @Schema(description = "字典标签") + private String label; + + @Schema(description = "字典值") + private String value; + + @Schema(description = "排序") + private Integer sort; + + @Schema(description = "状态(1:启用,0:禁用)") + private Integer status; + +} + + \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/system/model/vo/DictPageVO.java b/src/main/java/com/youlai/boot/system/model/vo/DictPageVO.java new file mode 100644 index 0000000..5185146 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/vo/DictPageVO.java @@ -0,0 +1,31 @@ +package com.youlai.boot.system.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +/** + * 字典分页Vo + * + * @author Ray + * @since 0.0.1 + */ +@Schema(description = "字典分页对象") +@Getter +@Setter +public class DictPageVO { + + @Schema(description = "字典ID") + private Long id; + + @Schema(description = "字典名称") + private String name; + + @Schema(description = "字典编码") + private String dictCode; + + @Schema(description = "字典状态(1-启用,0-禁用)") + private Integer status; +} + + \ No newline at end of file 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 new file mode 100644 index 0000000..cd8fd34 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/vo/LogPageVO.java @@ -0,0 +1,77 @@ +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; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 系统日志分页Vo + * + * @author Ray + * @since 2.10.0 + */ +@Data +@Schema(description = "系统日志分页Vo") +public class LogPageVO implements Serializable { + + @Schema(description = "主键") + private Long id; + + @Schema(description = "模块") + private LogModuleEnum module; + + @Schema(description = "操作类型") + private ActionTypeEnum actionType; + + @Schema(description = "操作标题") + private String title; + + @Schema(description = "自定义日志内容") + private String content; + + @Schema(description = "操作人ID") + private Long operatorId; + + @Schema(description = "操作人名称") + private String operatorName; + + @Schema(description = "请求路径") + private String requestUri; + + @Schema(description = "请求方法") + private String requestMethod; + + @Schema(description = "IP 地址") + private String ip; + + @Schema(description = "地区") + private String region; + + @Schema(description = "设备") + private String device; + + @Schema(description = "操作系统") + private String os; + + @Schema(description = "浏览器") + private String browser; + + @Schema(description = "状态:0失败 1成功") + private Integer status; + + @Schema(description = "执行时间(毫秒)") + private Integer executionTime; + + @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/MenuVO.java b/src/main/java/com/youlai/boot/system/model/vo/MenuVO.java new file mode 100644 index 0000000..8101059 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/vo/MenuVO.java @@ -0,0 +1,54 @@ +package com.youlai.boot.system.model.vo; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Schema(description ="菜单视图对象") +@Data +public class MenuVO { + + @Schema(description = "菜单ID") + private Long id; + + @Schema(description = "父菜单ID") + private Long parentId; + + @Schema(description = "菜单名称") + private String name; + + @Schema(description="菜单类型(C-目录 M-菜单 B-按钮)") + private String type; + + @Schema(description = "路由名称") + private String routeName; + + @Schema(description = "路由路径") + private String routePath; + + @Schema(description = "组件路径") + private String component; + + @Schema(description = "菜单排序(数字越小排名越靠前)") + private Integer sort; + + @Schema(description = "菜单是否可见(1:显示;0:隐藏)") + private Integer visible; + + @Schema(description = "ICON") + private String icon; + + @Schema(description = "跳转路径") + private String redirect; + + @Schema(description="按钮权限标识") + private String perm; + + @Schema(description = "子菜单") + @JsonInclude(value = JsonInclude.Include.NON_NULL) + private List children; +} + + \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/system/model/vo/NoticeDetailVO.java b/src/main/java/com/youlai/boot/system/model/vo/NoticeDetailVO.java new file mode 100644 index 0000000..cfdf46f --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/vo/NoticeDetailVO.java @@ -0,0 +1,45 @@ +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 java.time.LocalDateTime; + +/** + * 阅读通知公告Vo + * + * @author Theo + * @since 2024-9-8 01:25:06 + */ +@Data +public class NoticeDetailVO { + + @Schema(description = "通知ID") + private Long id; + + @Schema(description = "通知标题") + private String title; + + @Schema(description = "通知内容") + private String content; + + @Schema(description = "通知类型") + private Integer type; + + @Schema(description = "发布人") + private String publisherName; + + @Schema(description = "优先级(L-低 M-中 H-高)") + private String level; + + @Schema(description = "发布状态(0-未发布 1已发布 2已撤回) 冗余字段,方便判断是否已经发布") + private Integer publishStatus; + + @Schema(description = "发布时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime publishTime; +} + + + \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/system/model/vo/NoticePageVO.java b/src/main/java/com/youlai/boot/system/model/vo/NoticePageVO.java new file mode 100644 index 0000000..de0b480 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/vo/NoticePageVO.java @@ -0,0 +1,63 @@ +package com.youlai.boot.system.model.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 通知公告视图对象 + * + * @author youlaitech + * @since 2024-08-27 10:31 + */ +@Getter +@Setter +@Schema(description = "通知公告视图对象") +public class NoticePageVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "通知ID") + private Long id; + + @Schema(description = "通知标题") + private String title; + + @Schema(description = "通知状态") + private Integer publishStatus; + + @Schema(description = "通知类型") + private Integer type; + + @Schema(description = "发布人姓名") + private String publisherName; + + @Schema(description = "通知等级") + private String level; + + @Schema(description = "发布时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm") + private LocalDateTime publishTime; + + @Schema(description = "是否已读") + private Integer isRead; + + @Schema(description = "目标类型") + private Integer targetType; + + @Schema(description = "创建时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm") + private LocalDateTime createTime; + + @Schema(description = "撤回时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm") + private LocalDateTime revokeTime; +} + + \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/system/model/vo/NoticeVO.java b/src/main/java/com/youlai/boot/system/model/vo/NoticeVO.java new file mode 100644 index 0000000..47f491c --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/vo/NoticeVO.java @@ -0,0 +1,31 @@ +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 java.time.LocalDateTime; + +/** + * 通知视图对象 + * + * @author Theo + * @since 2024-9-2 14:32:58 + */ +@Data +public class NoticeVO { + + @Schema(description = "通知ID") + private Long id; + + @Schema(description = "通知类型") + private Integer type; + + @Schema(description = "通知标题") + private String title; + + @Schema(description = "通知时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm") + private LocalDateTime publishTime; + +} \ No newline at end of file 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 new file mode 100644 index 0000000..92a975b --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/vo/RolePageVO.java @@ -0,0 +1,39 @@ +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 java.time.LocalDateTime; + +@Schema(description ="角色分页对象") +@Data +public class RolePageVO { + + @Schema(description="角色ID") + private Long id; + + @Schema(description="角色名称") + private String name; + + @Schema(description="角色编码") + private String code; + + @Schema(description="角色状态") + private Integer status; + + @Schema(description="排序") + private Integer sort; + + @Schema(description="数据权限(1-所有数据 2-部门及子部门数据 3-本部门数据 4-本人数据 5-自定义部门数据)") + private Integer dataScope; + + @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/RouteVO.java b/src/main/java/com/youlai/boot/system/model/vo/RouteVO.java new file mode 100644 index 0000000..a07ed41 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/vo/RouteVO.java @@ -0,0 +1,65 @@ +package com.youlai.boot.system.model.vo; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * 菜单路由视图对象 + * + * @author haoxr + * @since 2020/11/28 + */ +@Schema(description = "路由对象") +@Data +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class RouteVO { + + @Schema(description = "路由路径", example = "user") + private String path; + + @Schema(description = "组件路径", example = "system/user/index") + private String component; + + @Schema(description = "跳转链接", example = "https://www.youlai.tech") + private String redirect; + + @Schema(description = "路由名称") + private String name; + + @Schema(description = "路由属性") + private Meta meta; + + @Schema(description = "路由属性类型") + @Data + public static class Meta { + + @Schema(description = "路由title") + private String title; + + @Schema(description = "ICON") + private String icon; + + @Schema(description = "是否隐藏(true-是 false-否)", example = "true") + private Boolean hidden; + + @Schema(description = "【菜单】是否开启页面缓存", example = "true") + @JsonInclude(JsonInclude.Include.NON_NULL) + private Boolean keepAlive; + + @Schema(description = "【目录】只有一个子路由是否始终显示", example = "true") + @JsonInclude(JsonInclude.Include.NON_NULL) + private Boolean alwaysShow; + + @Schema(description = "路由参数") + private Map params; + } + + @Schema(description = "子路由列表") + private List children; +} + + \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/system/model/vo/UserExportVO.java b/src/main/java/com/youlai/boot/system/model/vo/UserExportVO.java new file mode 100644 index 0000000..c538339 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/vo/UserExportVO.java @@ -0,0 +1,44 @@ +package com.youlai.boot.system.model.vo; + +import cn.idev.excel.annotation.ExcelProperty; +import cn.idev.excel.annotation.format.DateTimeFormat; +import cn.idev.excel.annotation.write.style.ColumnWidth; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 用户导出视图对象 + * + * @author haoxr + * @since 2022/4/11 8:46 + */ + +@Data +@ColumnWidth(20) +public class UserExportVO { + + @ExcelProperty(value = "用户名") + private String username; + + @ExcelProperty(value = "用户昵称") + private String nickname; + + @ExcelProperty(value = "部门") + private String deptName; + + @ExcelProperty(value = "性别") + private String gender; + + @ExcelProperty(value = "手机号码") + private String mobile; + + @ExcelProperty(value = "邮箱") + private String email; + + @ExcelProperty(value = "创建时间") + @DateTimeFormat("yyyy/MM/dd HH:mm:ss") + private LocalDateTime createTime; + + +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/system/model/vo/UserNoticePageVO.java b/src/main/java/com/youlai/boot/system/model/vo/UserNoticePageVO.java new file mode 100644 index 0000000..db2a7fb --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/vo/UserNoticePageVO.java @@ -0,0 +1,42 @@ +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 java.time.LocalDateTime; + +/** + * 用户公告Vo + * + * @author Theo + * @since 2024-08-28 16:56 + */ +@Data +@Schema(description = "用户公告Vo") +public class UserNoticePageVO { + + @Schema(description = "通知ID") + private Long id; + + @Schema(description = "通知标题") + private String title; + + @Schema(description = "通知类型") + private Integer type; + + @Schema(description = "通知等级") + private String level; + + @Schema(description = "发布人姓名") + private String publisherName; + + @Schema(description = "发布时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm") + private LocalDateTime publishTime; + + @Schema(description = "是否已读") + private Integer isRead; +} + + \ 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 new file mode 100644 index 0000000..d911182 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/vo/UserPageVO.java @@ -0,0 +1,54 @@ +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 java.time.LocalDateTime; + +/** + * 用户分页视图对象 + * + * @author haoxr + * @since 2022/1/15 9:41 + */ +@Schema(description ="用户分页对象") +@Data +public class UserPageVO { + + @Schema(description="用户ID") + private Long id; + + @Schema(description="用户名") + private String username; + + @Schema(description="用户昵称") + private String nickname; + + @Schema(description="手机号") + private String mobile; + + @Schema(description="性别") + private Integer gender; + + @Schema(description="用户头像地址") + private String avatar; + + @Schema(description="用户邮箱") + private String email; + + @Schema(description="用户状态(1:启用;0:禁用)") + private Integer status; + + @Schema(description="部门名称") + private String deptName; + + @Schema(description="角色名称,多个使用英文逗号(,)分割") + private String roleNames; + + @Schema(description="创建时间") + @JsonFormat(pattern = "yyyy/MM/dd HH:mm") + private LocalDateTime createTime; +} + + \ No newline at end of file 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 new file mode 100644 index 0000000..39ebd9a --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/vo/UserProfileVO.java @@ -0,0 +1,51 @@ +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 java.util.Date; + +/** + * 个人中心用户信息 + * + * @author Ray + * @since 2024/8/13 + */ +@Schema(description = "个人中心用户信息") +@Data +public class UserProfileVO { + + @Schema(description = "用户ID") + private Long id; + + @Schema(description = "用户名") + private String username; + + @Schema(description = "用户昵称") + private String nickname; + + @Schema(description = "头像URL") + private String avatar; + + @Schema(description = "性别") + private Integer gender; + + @Schema(description = "手机号") + private String mobile; + + @Schema(description = "邮箱") + private String email; + + @Schema(description = "部门名称") + private String deptName; + + @Schema(description = "角色名称") + private String roleNames; + + @Schema(description = "创建时间") + @JsonFormat(pattern = "yyyy-MM-dd") + private Date createTime; +} + + \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/system/model/vo/VisitOverviewVO.java b/src/main/java/com/youlai/boot/system/model/vo/VisitOverviewVO.java new file mode 100644 index 0000000..63cfb0d --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/vo/VisitOverviewVO.java @@ -0,0 +1,37 @@ +package com.youlai.boot.system.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; + +/** + * 访问总览视图对象 + * + * @author Ray.Hao + * @since 2024/7/2 + */ +@Schema(description = "访问总览视图对象") +@Getter +@Setter +public class VisitOverviewVO { + + @Schema(description = "今日独立访客数 (UV)") + private Integer todayUvCount; + + @Schema(description = "累计独立访客数 (UV)") + private Integer totalUvCount; + + @Schema(description = "独立访客增长率") + private BigDecimal uvGrowthRate; + + @Schema(description = "今日页面浏览量 (PV)") + private Integer todayPvCount; + + @Schema(description = "累计页面浏览量 (PV)") + private Integer totalPvCount; + + @Schema(description = "页面浏览量增长率") + private BigDecimal pvGrowthRate; +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/system/model/vo/VisitTrendVO.java b/src/main/java/com/youlai/boot/system/model/vo/VisitTrendVO.java new file mode 100644 index 0000000..96954a4 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/vo/VisitTrendVO.java @@ -0,0 +1,30 @@ +package com.youlai.boot.system.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +/** + * 访问趋势Vo + * + * @author Ray.Hao + * @since 2.3.0 + */ +@Schema(description = "访问趋势Vo") +@Getter +@Setter +public class VisitTrendVO { + + @Schema(description = "日期列表") + private List dates; + + @Schema(description = "浏览量(PV)") + private List pvList; + + @Schema(description = "访客数(UV)") + private List uvList; +} + + \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/system/service/ConfigService.java b/src/main/java/com/youlai/boot/system/service/ConfigService.java new file mode 100644 index 0000000..d663826 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/service/ConfigService.java @@ -0,0 +1,68 @@ +package com.youlai.boot.system.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.IService; +import com.youlai.boot.system.model.entity.Config; +import com.youlai.boot.system.model.form.ConfigForm; +import com.youlai.boot.system.model.query.ConfigQuery; +import com.youlai.boot.system.model.vo.ConfigVO; + +/** + * 系统配置Service接口 + * + * @author Theo + * @since 2024-07-29 11:17:26 + */ +public interface ConfigService extends IService { + + /** + * 分页查询系统配置 + * @param sysConfigPageQuery 查询参数 + * @return 系统配置分页列表 + */ + IPage page(ConfigQuery sysConfigPageQuery); + + /** + * 保存系统配置 + * @param sysConfigForm 系统配置表单 + * @return 是否保存成功 + */ + boolean save(ConfigForm sysConfigForm); + + /** + * 获取系统配置表单数据 + * + * @param id 系统配置ID + * @return 系统配置表单数据 + */ + ConfigForm getConfigFormData(Long id); + + /** + * 编辑系统配置 + * @param id 系统配置ID + * @param sysConfigForm 系统配置表单 + * @return 是否编辑成功 + */ + boolean edit(Long id, ConfigForm sysConfigForm); + + /** + * 删除系统配置 + * @param ids 系统配置ID + * @return 是否删除成功 + */ + boolean delete(Long ids); + + /** + * 刷新系统配置缓存 + * @return 是否刷新成功 + */ + boolean refreshCache(); + + /** + * 获取系统配置 + * @param key 配置键 + * @return 配置值 + */ + Object getSystemConfig(String key); + +} diff --git a/src/main/java/com/youlai/boot/system/service/DeptService.java b/src/main/java/com/youlai/boot/system/service/DeptService.java new file mode 100644 index 0000000..850a948 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/service/DeptService.java @@ -0,0 +1,65 @@ +package com.youlai.boot.system.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.youlai.boot.system.model.entity.Dept; +import com.youlai.boot.common.model.Option; +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 java.util.List; + +/** + * 部门业务接口 + * + * @author haoxr + * @since 2021/8/22 + */ +public interface DeptService extends IService { + /** + * 部门列表 + * + * @return 部门列表 + */ + List getDeptList(DeptQuery queryParams); + + /** + * 部门树形下拉选项 + * + * @return 部门树形下拉选项 + */ + List> listDeptOptions(); + + /** + * 新增部门 + * + * @param formData 部门表单 + * @return 部门ID + */ + Long saveDept(DeptForm formData); + + /** + * 修改部门 + * + * @param deptId 部门ID + * @param formData 部门表单 + * @return 部门ID + */ + Long updateDept(Long deptId, DeptForm formData); + + /** + * 删除部门 + * + * @param ids 部门ID,多个以英文逗号,拼接字符串 + * @return 是否成功 + */ + boolean deleteByIds(String ids); + + /** + * 获取部门详情 + * + * @param deptId 部门ID + * @return 部门详情 + */ + DeptForm getDeptForm(Long deptId); +} diff --git a/src/main/java/com/youlai/boot/system/service/DictItemService.java b/src/main/java/com/youlai/boot/system/service/DictItemService.java new file mode 100644 index 0000000..d7a64ef --- /dev/null +++ b/src/main/java/com/youlai/boot/system/service/DictItemService.java @@ -0,0 +1,68 @@ +package com.youlai.boot.system.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.IService; +import com.youlai.boot.system.model.entity.DictItem; +import com.youlai.boot.system.model.form.DictItemForm; +import com.youlai.boot.system.model.query.DictItemQuery; +import com.youlai.boot.system.model.vo.DictItemOptionVO; +import com.youlai.boot.system.model.vo.DictItemPageVO; + +import java.util.List; + +/** + * 字典项接口 + * + * @author Ray Hao + * @since 2023/3/4 + */ +public interface DictItemService extends IService { + + /** + * 字典项分页列表 + * + * @param queryParams 查询参数 + * @return 字典项分页列表 + */ + Page getDictItemPage(DictItemQuery queryParams); + + /** + * 获取字典项列表 + * + * @param dictCode 字典编码 + * @return 字典项列表 + */ + List getDictItems(String dictCode); + + /** + * 获取字典项表单 + * + * @param itemId 字典项ID + * @return 字典项表单 + */ + DictItemForm getDictItemForm(Long itemId); + + /** + * 保存字典项 + * + * @param formData 字典项表单 + * @return 是否成功 + */ + boolean saveDictItem(DictItemForm formData); + + /** + * 更新字典项 + * + * @param formData 字典项表单 + * @return 是否成功 + */ + boolean updateDictItem(DictItemForm formData); + + /** + * 删除字典项 + * + * @param ids 字典项ID,多个逗号分隔 + */ + void deleteDictItemByIds(String ids); + +} diff --git a/src/main/java/com/youlai/boot/system/service/DictService.java b/src/main/java/com/youlai/boot/system/service/DictService.java new file mode 100644 index 0000000..a064ded --- /dev/null +++ b/src/main/java/com/youlai/boot/system/service/DictService.java @@ -0,0 +1,76 @@ +package com.youlai.boot.system.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.IService; +import com.youlai.boot.common.model.Option; +import com.youlai.boot.system.model.entity.Dict; +import com.youlai.boot.system.model.form.DictForm; +import com.youlai.boot.system.model.query.DictQuery; +import com.youlai.boot.system.model.vo.DictItemOptionVO; +import com.youlai.boot.system.model.vo.DictPageVO; + +import java.util.List; + +/** + * 字典业务接口 + * + * @author haoxr + * @since 2022/10/12 + */ +public interface DictService extends IService { + + /** + * 获取字典分页列表 + * + * @param queryParams 分页查询对象 + * @return 字典分页列表 + */ + Page getDictPage(DictQuery queryParams); + + /** + * 获取字典列表 + * + * @return 字典列表 + */ + List> getDictList(); + + /** + * 获取字典表单数据 + * + * @param id 字典ID + * @return 字典表单 + */ + DictForm getDictForm(Long id); + + /** + * 新增字典 + * + * @param dictForm 字典表单 + * @return 是否成功 + */ + boolean saveDict(DictForm dictForm); + + /** + * 修改字典 + * + * @param id 字典ID + * @param dictForm 字典表单 + * @return 是否成功 + */ + boolean updateDict(Long id, DictForm dictForm); + + /** + * 删除字典 + * + * @param ids 字典ID集合 + */ + void deleteDictByIds(List ids); + + /** + * 根据字典ID列表获取字典编码列表 + * + * @param ids 字典ID列表 + * @return 字典编码列表 + */ + List getDictCodesByIds(List ids); +} diff --git a/src/main/java/com/youlai/boot/system/service/LogService.java b/src/main/java/com/youlai/boot/system/service/LogService.java new file mode 100644 index 0000000..bd2f447 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/service/LogService.java @@ -0,0 +1,40 @@ +package com.youlai.boot.system.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.system.model.entity.SysLog; +import com.baomidou.mybatisplus.extension.service.IService; +import com.youlai.boot.system.model.query.LogQuery; +import com.youlai.boot.system.model.vo.LogPageVO; +import com.youlai.boot.system.model.vo.VisitOverviewVO; +import com.youlai.boot.system.model.vo.VisitTrendVO; + +import java.time.LocalDate; + +/** + * 系统日志 服务接口 + * + * @author Ray.Hao + * @since 2.10.0 + */ +public interface LogService extends IService { + + /** + * 获取日志分页列表 + */ + Page getLogPage(LogQuery queryParams); + + + /** + * 获取访问趋势 + * + * @param startDate 开始时间 + * @param endDate 结束时间 + */ + VisitTrendVO getVisitTrend(LocalDate startDate, LocalDate endDate); + + /** + * 获取访问统计 + */ + VisitOverviewVO getVisitStats(); + +} diff --git a/src/main/java/com/youlai/boot/system/service/MenuService.java b/src/main/java/com/youlai/boot/system/service/MenuService.java new file mode 100644 index 0000000..1429226 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/service/MenuService.java @@ -0,0 +1,82 @@ +package com.youlai.boot.system.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.youlai.boot.codegen.model.entity.GenTable; +import com.youlai.boot.system.model.form.MenuForm; +import com.youlai.boot.common.model.Option; +import com.youlai.boot.system.model.entity.Menu; +import com.youlai.boot.system.model.query.MenuQuery; +import com.youlai.boot.system.model.vo.MenuVO; +import com.youlai.boot.system.model.vo.RouteVO; + +import java.util.List; + +/** + * 菜单业务接口 + * + * @author haoxr + * @since 2020/11/06 + */ +public interface MenuService extends IService

{ + + /** + * 获取菜单表格列表 + */ + List listMenus(MenuQuery queryParams); + + /** + * 获取菜单下拉列表 + * + * @param onlyParent 是否只查询父级菜单 + */ + List> listMenuOptions(boolean onlyParent); + + /** + * 新增菜单 + * + * @param menuForm 菜单表单对象 + */ + boolean saveMenu(MenuForm menuForm); + + /** + * 获取当前用户的菜单路由列表 + */ + List listCurrentUserRoutes(); + + /** + * 获取当前用户的菜单路由列表(指定数据源) + * + * @param datasource 数据源名称,如:master(主库)、naiveui(NaiveUI数据库)、template(模板数据库) + */ + List listCurrentUserRoutes(String datasource); + + /** + * 修改菜单显示状态 + * + * @param menuId 菜单ID + * @param visible 是否显示(1-显示 0-隐藏) + */ + boolean updateMenuVisible(Long menuId, Integer visible); + + /** + * 获取菜单表单数据 + * + * @param id 菜单ID + */ + MenuForm getMenuForm(Long id); + + /** + * 删除菜单 + * + * @param id 菜单ID + */ + boolean deleteMenu(Long id); + + /** + * 代码生成时添加菜单 + * + * @param parentMenuId 父菜单ID + * @param genConfig 实体名 + */ + void addMenuForCodegen(Long parentMenuId, GenTable genTable); +} diff --git a/src/main/java/com/youlai/boot/system/service/NoticeService.java b/src/main/java/com/youlai/boot/system/service/NoticeService.java new file mode 100644 index 0000000..951366c --- /dev/null +++ b/src/main/java/com/youlai/boot/system/service/NoticeService.java @@ -0,0 +1,91 @@ +package com.youlai.boot.system.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.IService; +import com.youlai.boot.system.model.entity.Notice; +import com.youlai.boot.system.model.form.NoticeForm; +import com.youlai.boot.system.model.query.NoticeQuery; +import com.youlai.boot.system.model.vo.NoticePageVO; +import com.youlai.boot.system.model.vo.UserNoticePageVO; +import com.youlai.boot.system.model.vo.NoticeDetailVO; + +/** + * 通知公告服务类 + * + * @author youlaitech + * @since 2024-08-27 10:31 + */ +public interface NoticeService extends IService { + + /** + * 通知公告分页列表 + * + * @return 通知公告分页列表 + */ + IPage getNoticePage(NoticeQuery queryParams); + + /** + * 获取通知公告表单数据 + * + * @param id 通知公告ID + * @return 通知公告表单对象 + */ + NoticeForm getNoticeFormData(Long id); + + /** + * 新增通知公告 + * + * @param formData 通知公告表单对象 + * @return 是否新增成功 + */ + boolean saveNotice(NoticeForm formData); + + /** + * 修改通知公告 + * + * @param id 通知公告ID + * @param formData 通知公告表单对象 + * @return 是否修改成功 + */ + boolean updateNotice(Long id, NoticeForm formData); + + /** + * 删除通知公告 + * + * @param ids 通知公告ID,多个以英文逗号(,)分割 + * @return 是否删除成功 + */ + boolean deleteNotices(String ids); + + /** + * 发布通知公告 + * + * @param id 通知公告ID + * @return 是否发布成功 + */ + boolean publishNotice(Long id); + + /** + * 撤回通知公告 + * + * @param id 通知公告ID + * @return 是否撤回成功 + */ + boolean revokeNotice(Long id); + + /** + * 阅读获取通知公告详情 + * + * @param id 通知公告ID + * @return 通知公告详情 + */ + NoticeDetailVO getNoticeDetail(Long id); + + /** + * 获取我的通知公告分页列表 + * + * @param queryParams 查询参数 + * @return 通知公告分页列表 + */ + IPage getMyNoticePage(NoticeQuery queryParams); +} diff --git a/src/main/java/com/youlai/boot/system/service/RoleDeptService.java b/src/main/java/com/youlai/boot/system/service/RoleDeptService.java new file mode 100644 index 0000000..de8ba84 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/service/RoleDeptService.java @@ -0,0 +1,47 @@ +package com.youlai.boot.system.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.youlai.boot.system.model.entity.RoleDept; + +import java.util.List; + +/** + * 角色部门关联服务接口 + * + * @author Ray.Hao + * @since 4.1.0 + */ +public interface RoleDeptService extends IService { + + /** + * 根据角色ID获取部门ID列表 + * + * @param roleId 角色ID + * @return 部门ID列表 + */ + List getDeptIdsByRoleId(Long roleId); + + /** + * 根据角色编码集合获取所有部门ID列表(用于自定义数据权限) + * + * @param roleCodes 角色编码集合 + * @return 部门ID列表 + */ + List getDeptIdsByRoleCodes(List roleCodes); + + /** + * 保存角色部门关联 + * + * @param roleId 角色ID + * @param deptIds 部门ID列表 + */ + void saveRoleDepts(Long roleId, List deptIds); + + /** + * 删除角色部门关联 + * + * @param roleId 角色ID + */ + void deleteByRoleId(Long roleId); + +} diff --git a/src/main/java/com/youlai/boot/system/service/RoleMenuService.java b/src/main/java/com/youlai/boot/system/service/RoleMenuService.java new file mode 100644 index 0000000..1a2d948 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/service/RoleMenuService.java @@ -0,0 +1,56 @@ +package com.youlai.boot.system.service; + + +import com.baomidou.mybatisplus.extension.service.IService; +import com.youlai.boot.system.model.entity.RoleMenu; + +import java.util.List; +import java.util.Set; + +/** + * 角色菜单业务接口 + * + * @author Ray.Hao + * @since 2.5.0 + */ +public interface RoleMenuService extends IService { + + /** + * 获取角色拥有的菜单ID集合 + * + * @param roleId 角色ID + * @return 菜单ID集合 + */ + List listMenuIdsByRoleId(Long roleId); + + + /** + * 刷新权限缓存(所有角色) + */ + void refreshRolePermsCache(); + + /** + * 刷新权限缓存(指定角色) + * + * @param roleCode 角色编码 + */ + void refreshRolePermsCache(String roleCode); + + /** + * 刷新权限缓存(修改角色编码时调用) + * + * @param oldRoleCode 旧角色编码 + * @param newRoleCode 新角色编码 + */ + void refreshRolePermsCache(String oldRoleCode, String newRoleCode); + + /** + * 获取角色权限集合(带缓存) + *

+ * 采用 Read-Through 缓存策略,缓存未命中时自动回源数据库 + * + * @param roleCodes 角色编码集合 + * @return 权限集合 + */ + Set getRolePermsByRoleCodes(Set roleCodes); +} diff --git a/src/main/java/com/youlai/boot/system/service/RoleService.java b/src/main/java/com/youlai/boot/system/service/RoleService.java new file mode 100644 index 0000000..8ade9ed --- /dev/null +++ b/src/main/java/com/youlai/boot/system/service/RoleService.java @@ -0,0 +1,113 @@ +package com.youlai.boot.system.service; + + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.IService; +import com.youlai.boot.framework.security.model.RoleDataScope; +import com.youlai.boot.system.model.entity.Role; +import com.youlai.boot.common.model.Option; +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 java.util.List; +import java.util.Set; + +/** + * 角色业务接口层 + * + * @author haoxr + * @since 2022/6/3 + */ +public interface RoleService extends IService { + + /** + * 角色分页列表 + * + * @param queryParams + * @return + */ + Page getRolePage(RoleQuery queryParams); + + + /** + * 角色下拉列表 + * + * @return + */ + List> listRoleOptions(); + + /** + * + * @param roleForm + * @return + */ + boolean saveRole(RoleForm roleForm); + + /** + * 获取角色表单数据 + * + * @param roleId 角色ID + * @return {@link RoleForm} – 角色表单数据 + */ + RoleForm getRoleForm(Long roleId); + + /** + * 修改角色状态 + * + * @param roleId 角色ID + * @param status 角色状态(1:启用;0:禁用) + * @return {@link Boolean} + */ + boolean updateRoleStatus(Long roleId, Integer status); + + /** + * 批量删除角色 + * + * @param ids 角色ID,多个使用英文逗号(,)分割 + */ + void deleteRoles(String ids); + + /** + * 获取角色的菜单ID集合 + * + * @param roleId 角色ID + * @return 菜单ID集合(包括按钮权限ID) + */ + List getRoleMenuIds(Long roleId); + + /** + * 修改角色的资源权限 + * + * @param roleId 角色ID + * @param menuIds 菜单ID集合 + */ + void assignMenusToRole(Long roleId, List menuIds); + + /** + * 获取最大范围的数据权限 + * + * @param roles + * @return + */ + Integer getMaximumDataScope(Set roles); + + /** + * 获取角色的部门ID列表(自定义数据权限) + * + * @param roleId 角色ID + * @return 部门ID列表 + */ + List getRoleDeptIds(Long roleId); + + /** + * 获取用户所有角色的数据权限列表 + *

+ * 用于实现多角色数据权限合并(并集策略) + * + * @param roleCodes 角色编码集合 + * @return 角色数据权限列表 + */ + List getRoleDataScopes(Set roleCodes); + +} diff --git a/src/main/java/com/youlai/boot/system/service/UserNoticeService.java b/src/main/java/com/youlai/boot/system/service/UserNoticeService.java new file mode 100644 index 0000000..4f4c774 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/service/UserNoticeService.java @@ -0,0 +1,35 @@ +package com.youlai.boot.system.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.IService; +import com.youlai.boot.system.model.entity.UserNotice; +import com.youlai.boot.system.model.query.NoticeQuery; +import com.youlai.boot.system.model.vo.UserNoticePageVO; +import com.youlai.boot.system.model.vo.NoticePageVO; + +import java.util.List; + +/** + * 用户公告状态服务类 + * + * @author Theo + * @since 2024-08-28 16:56 + */ +public interface UserNoticeService extends IService { + + /** + * 全部标记为已读 + * + * @return 是否成功 + */ + boolean readAll(); + + /** + * 分页获取我的通知公告 + * @param page 分页对象 + * @param queryParams 查询参数 + * @return 我的通知公告分页列表 + */ + IPage getMyNoticePage(Page page, NoticeQuery queryParams); +} diff --git a/src/main/java/com/youlai/boot/system/service/UserRoleService.java b/src/main/java/com/youlai/boot/system/service/UserRoleService.java new file mode 100644 index 0000000..1117e97 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/service/UserRoleService.java @@ -0,0 +1,41 @@ +package com.youlai.boot.system.service; + + +import com.baomidou.mybatisplus.extension.service.IService; +import com.youlai.boot.system.model.entity.UserRole; + +import java.util.List; + +/** + * 用户角色业务接口 + * + * @author Ray.Hao + * @since 0.0.1 + */ +public interface UserRoleService extends IService { + + /** + * 保存用户角色 + * + * @param userId 用户ID + * @param roleIds 角色ID列表 + * @return + */ + void saveUserRoles(Long userId, List roleIds); + + /** + * 判断角色是否存在绑定的用户 + * + * @param roleId 角色ID + * @return true:已分配 false:未分配 + */ + boolean hasAssignedUsers(Long roleId); + + /** + * 获取角色绑定的用户ID集合 + * + * @param roleId 角色ID + * @return 用户ID集合 + */ + List listUserIdsByRoleId(Long roleId); +} diff --git a/src/main/java/com/youlai/boot/system/service/UserService.java b/src/main/java/com/youlai/boot/system/service/UserService.java new file mode 100644 index 0000000..d0abf23 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/service/UserService.java @@ -0,0 +1,196 @@ +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.system.model.vo.CurrentUserVO; +import com.youlai.boot.system.model.vo.UserExportVO; +import com.youlai.boot.system.model.entity.SysUser; +import com.youlai.boot.system.model.query.UserQuery; +import com.youlai.boot.system.model.vo.UserPageVO; +import com.youlai.boot.system.model.vo.UserProfileVO; +import com.youlai.boot.system.model.form.*; + +import java.util.List; + +/** + * 用户业务接口 + * + * @author Ray.Hao + * @since 2022/1/14 + */ +public interface UserService extends IService { + + /** + * 用户分页列表 + * + * @return {@link IPage} 用户分页列表 + */ + IPage getUserPage(UserQuery queryParams); + + /** + * 获取用户表单数据 + * + * @param userId 用户ID + * @return {@link UserForm} 用户表单数据 + */ + UserForm getUserFormData(Long userId); + + + /** + * 新增用户 + * + * @param userForm 用户表单对象 + * @return {@link Boolean} 是否新增成功 + */ + boolean saveUser(UserForm userForm); + + /** + * 修改用户 + * + * @param userId 用户ID + * @param userForm 用户表单对象 + * @return {@link Boolean} 是否修改成功 + */ + boolean updateUser(Long userId, UserForm userForm); + + + /** + * 删除用户 + * + * @param idsStr 用户ID,多个以英文逗号(,)分割 + * @return {@link Boolean} 是否删除成功 + */ + boolean deleteUsers(String idsStr); + + + /** + * 根据用户名获取认证信息 + * + * @param username 用户名 + * @return {@link UserAuthInfo} + */ + UserAuthInfo getAuthInfoByUsername(String username); + + default UserAuthInfo getAuthCredentialsByUsername(String username) { + return getAuthInfoByUsername(username); + } + + + /** + * 获取导出用户列表 + * + * @param queryParams 查询参数 + * @return {@link List} 导出用户列表 + */ + List listExportUsers(UserQuery queryParams); + + + /** + * 获取登录用户信息 + * + * @return {@link CurrentUserVO} 登录用户信息 + */ + CurrentUserVO getCurrentUserInfo(); + + /** + * 获取个人中心用户信息 + * + * @return {@link UserProfileVO} 个人中心用户信息 + */ + UserProfileVO getUserProfile(Long userId); + + /** + * 修改个人中心用户信息 + * + * @param formData 表单数据 + * @return {@link Boolean} 是否修改成功 + */ + boolean updateUserProfile(UserProfileForm formData); + + /** + * 修改指定用户密码 + * + * @param userId 用户ID + * @param data 修改密码表单数据 + * @return {@link Boolean} 是否修改成功 + */ + boolean changeUserPassword(Long userId, PasswordUpdateForm data); + + /** + * 重置指定用户密码 + * + * @param userId 用户ID + * @param password 重置后的密码 + * @return {@link Boolean} 是否重置成功 + */ + boolean resetUserPassword(Long userId, String password); + + /** + * 发送短信验证码(绑定或更换手机号) + * + * @param mobile 手机号 + * @return {@link Boolean} 是否发送成功 + */ + boolean sendMobileCode(String mobile); + + /** + * 修改当前用户手机号 + * + * @param data 表单数据 + * @return {@link Boolean} 是否修改成功 + */ + boolean bindOrChangeMobile(MobileUpdateForm data); + + /** + * 发送邮箱验证码(绑定或更换邮箱) + * + * @param email 邮箱 + */ + void sendEmailCode(String email); + + /** + * 绑定或更换邮箱 + * + * @param data 表单数据 + * @return {@link Boolean} 是否绑定成功 + */ + boolean bindOrChangeEmail(EmailUpdateForm data); + + /** + * 解绑手机号 + * + * @param data 表单数据 + * @return {@link Boolean} 是否解绑成功 + */ + boolean unbindMobile(PasswordVerifyForm data); + + /** + * 解绑邮箱 + * + * @param data 表单数据 + * @return {@link Boolean} 是否解绑成功 + */ + boolean unbindEmail(PasswordVerifyForm data); + + /** + * 获取用户选项列表 + * + * @return {@link List>} 用户选项列表 + */ + List> listUserOptions(); + + /** + * 根据手机号获取用户认证信息 + * + * @param mobile 手机号 + * @return {@link UserAuthInfo} + */ + UserAuthInfo getAuthInfoByMobile(String mobile); + + default UserAuthInfo getAuthCredentialsByMobile(String mobile) { + return getAuthInfoByMobile(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 new file mode 100644 index 0000000..943792d --- /dev/null +++ b/src/main/java/com/youlai/boot/system/service/UserSocialService.java @@ -0,0 +1,70 @@ +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.system.model.entity.UserSocial; + +/** + * 用户第三方账号绑定业务接口 + */ +public interface UserSocialService extends IService { + + /** + * 根据平台和openid查询绑定信息 + * + * @param platform 平台类型 + * @param openid openid + * @return 绑定信息 + */ + UserSocial getByPlatformAndOpenid(SocialPlatformEnum platform, String openid); + + /** + * 根据unionid查询绑定信息 + * + * @param unionid unionid + * @return 绑定信息 + */ + UserSocial getByUnionid(String unionid); + + /** + * 绑定或更新第三方账号 + * + * @param userId 用户ID + * @param platform 平台类型 + * @param openid openid + * @param unionid unionid + * @param nickname 昵称 + * @param avatar 头像 + * @param sessionKey session_key + * @return 绑定信息 + */ + UserSocial bindOrUpdate(Long userId, SocialPlatformEnum platform, String openid, String unionid, String nickname, String avatar, String sessionKey); + + /** + * 解绑第三方账号 + * + * @param userId 用户ID + * @param platform 平台类型 + * @return 是否成功 + */ + boolean unbind(Long userId, SocialPlatformEnum platform); + + /** + * 根据openid获取用户认证信息 + * + * @param platform 平台类型 + * @param openid openid + * @return 用户认证信息 + */ + UserAuthInfo getAuthInfoByOpenid(SocialPlatformEnum platform, String openid); + + /** + * 更新session_key + * + * @param id 绑定记录ID + * @param sessionKey session_key + */ + void updateSessionKey(Long id, String sessionKey); + +} 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 new file mode 100644 index 0000000..711b90c --- /dev/null +++ b/src/main/java/com/youlai/boot/system/service/impl/ConfigServiceImpl.java @@ -0,0 +1,159 @@ +package com.youlai.boot.system.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.youlai.boot.common.constant.RedisConstants; +import com.youlai.boot.system.converter.ConfigConverter; +import com.youlai.boot.system.mapper.ConfigMapper; +import com.youlai.boot.system.model.entity.Config; +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; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.util.Assert; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 系统配置Service接口实现 + * + * @author Theo + * @since 2024-07-29 11:17:26 + */ +@Service +@RequiredArgsConstructor +public class ConfigServiceImpl extends ServiceImpl implements ConfigService { + + private final ConfigConverter configConverter; + + private final RedisTemplate redisTemplate; + + /** + * 系统启动完成后,加载系统配置到缓存 + */ + @PostConstruct + public void init() { + refreshCache(); + } + + /** + * 分页查询系统配置 + * + * @param queryParams 查询参数 + * @return 系统配置分页列表 + */ + @Override + public IPage page(ConfigQuery queryParams) { + Page page = new Page<>(queryParams.getPageNum(), queryParams.getPageSize()); + String keywords = queryParams.getKeywords(); + LambdaQueryWrapper query = new LambdaQueryWrapper() + .and(StringUtils.isNotBlank(keywords), + q -> q.like(Config::getConfigKey, keywords) + .or() + .like(Config::getConfigName, keywords) + ); + Page pageList = this.page(page, query); + return configConverter.toPageVo(pageList); + } + + /** + * 保存系统配置 + * + * @param configForm 系统配置表单 + * @return 是否保存成功 + */ + @Override + public boolean save(ConfigForm configForm) { + Assert.isTrue( + super.count(new LambdaQueryWrapper().eq(Config::getConfigKey, configForm.getConfigKey())) == 0, + "配置键已存在"); + Config config = configConverter.toEntity(configForm); + config.setCreateBy(SecurityUtils.getUserId()); + return this.save(config); + } + + /** + * 获取系统配置表单数据 + * + * @param id 系统配置ID + * @return 系统配置表单数据 + */ + @Override + public ConfigForm getConfigFormData(Long id) { + Config entity = this.getById(id); + return configConverter.toForm(entity); + } + + /** + * 编辑系统配置 + * + * @param id 系统配置ID + * @param configForm 系统配置表单 + * @return 是否编辑成功 + */ + @Override + public boolean edit(Long id, ConfigForm configForm) { + Assert.isTrue( + 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); + } + + /** + * 删除系统配置 + * + * @param id 系统配置ID + * @return 是否删除成功 + */ + @Override + public boolean delete(Long id) { + if (id != null) { + return super.removeById(id); + } + return false; + } + + /** + * 刷新系统配置缓存 + * + * @return 是否刷新成功 + */ + @Override + public boolean refreshCache() { + redisTemplate.delete(RedisConstants.System.CONFIG); + List list = this.list(); + if (list != null) { + Map map = list.stream().collect(Collectors.toMap(Config::getConfigKey, Config::getConfigValue)); + redisTemplate.opsForHash().putAll(RedisConstants.System.CONFIG, map); + return true; + } + return false; + } + + /** + * 获取系统配置 + * + * @param key 配置键 + * @return 配置值 + */ + @Override + public Object getSystemConfig(String key) { + if (StringUtils.isNotBlank(key)) { + return redisTemplate.opsForHash().get(RedisConstants.System.CONFIG, key); + } + return null; + } + +} 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 new file mode 100644 index 0000000..51e0643 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/service/impl/DeptServiceImpl.java @@ -0,0 +1,273 @@ +package com.youlai.boot.system.service.impl; + +import cn.hutool.core.collection.CollectionUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.youlai.boot.framework.security.util.SecurityUtils; +import com.youlai.boot.system.converter.DeptConverter; +import com.youlai.boot.system.mapper.DeptMapper; +import com.youlai.boot.system.model.entity.Dept; +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.constant.SystemConstants; +import com.youlai.boot.common.enums.StatusEnum; +import com.youlai.boot.common.model.Option; +import com.youlai.boot.system.service.DeptService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 部门 业务实现类 + * + * @author Ray + * @since 2021/08/22 + */ +@Service +@RequiredArgsConstructor +public class DeptServiceImpl extends ServiceImpl implements DeptService { + + + private final DeptConverter deptConverter; + + /** + * 获取部门列表 + */ + @Override + public List getDeptList(DeptQuery queryParams) { + // 查询参数 + String keywords = queryParams.getKeywords(); + Integer status = queryParams.getStatus(); + + // 查询数据 + List deptList = this.list( + new LambdaQueryWrapper() + .like(StrUtil.isNotBlank(keywords), Dept::getName, keywords) + .eq(status != null, Dept::getStatus, status) + .orderByAsc(Dept::getSort) + ); + + if (CollectionUtil.isEmpty(deptList)) { + return Collections.EMPTY_LIST; + } + + // 获取所有部门ID + Set deptIds = deptList.stream() + .map(Dept::getId) + .collect(Collectors.toSet()); + // 获取父节点ID + Set parentIds = deptList.stream() + .map(Dept::getParentId) + .collect(Collectors.toSet()); + // 获取根节点ID(递归的起点),即父节点ID中不包含在部门ID中的节点,注意这里不能拿顶级部门 O 作为根节点,因为部门筛选的时候 O 会被过滤掉 + List rootIds = CollectionUtil.subtractToList(parentIds, deptIds); + + // 递归生成部门树形列表 + return rootIds.stream() + .flatMap(rootId -> recurDeptList(rootId, deptList).stream()) + .toList(); + } + + /** + * 递归生成部门树形列表 + * + * @param parentId 父ID + * @param deptList 部门列表 + * @return 部门树形列表 + */ + public List recurDeptList(Long parentId, List deptList) { + return deptList.stream() + .filter(dept -> dept.getParentId().equals(parentId)) + .map(dept -> { + DeptVO deptVo = deptConverter.toVo(dept); + List children = recurDeptList(dept.getId(), deptList); + deptVo.setChildren(children); + return deptVo; + }).toList(); + } + + /** + * 部门下拉选项 + * + * @return 部门下拉List集合 + */ + @Override + public List> listDeptOptions() { + + List deptList = this.list(new LambdaQueryWrapper() + .eq(Dept::getStatus, StatusEnum.ENABLE.getValue()) + .select(Dept::getId, Dept::getParentId, Dept::getName) + .orderByAsc(Dept::getSort) + ); + if (CollectionUtil.isEmpty(deptList)) { + return Collections.emptyList(); + } + + Set deptIds = deptList.stream() + .map(Dept::getId) + .collect(Collectors.toSet()); + + Set parentIds = deptList.stream() + .map(Dept::getParentId) + .collect(Collectors.toSet()); + + List rootIds = CollectionUtil.subtractToList(parentIds, deptIds); + + // 递归生成部门树形列表 + return rootIds.stream() + .flatMap(rootId -> recurDeptTreeOptions(rootId, deptList).stream()) + .toList(); + } + + /** + * 新增部门 + * + * @param formData 部门表单 + * @return 部门ID + */ + @Override + public Long saveDept(DeptForm formData) { + // 校验部门名称是否存在 + String code = formData.getCode(); + long count = this.count(new LambdaQueryWrapper() + .eq(Dept::getCode, code) + ); + Assert.isTrue(count == 0, "部门编号已存在"); + + // form->entity + Dept entity = deptConverter.toEntity(formData); + + // 生成部门路径(tree_path),格式:父节点tree_path + , + 父节点ID,用于删除部门时级联删除子部门 + String treePath = generateDeptTreePath(formData.getParentId()); + entity.setTreePath(treePath); + + entity.setCreateBy(SecurityUtils.getUserId()); + // 保存部门并返回部门ID + boolean result = this.save(entity); + Assert.isTrue(result, "部门保存失败"); + + return entity.getId(); + } + + + /** + * 获取部门表单 + * + * @param deptId 部门ID + * @return 部门表单对象 + */ + @Override + public DeptForm getDeptForm(Long deptId) { + Dept entity = this.getById(deptId); + return deptConverter.toForm(entity); + } + + + /** + * 更新部门 + * + * @param deptId 部门ID + * @param formData 部门表单 + * @return 部门ID + */ + @Override + public Long updateDept(Long deptId, DeptForm formData) { + // 校验部门名称/部门编号是否存在 + String code = formData.getCode(); + long count = this.count(new LambdaQueryWrapper() + .ne(Dept::getId, deptId) + .eq(Dept::getCode, code) + ); + Assert.isTrue(count == 0, "部门编号已存在"); + + + // form->entity + Dept entity = deptConverter.toEntity(formData); + entity.setId(deptId); + + // 生成部门路径(tree_path),格式:父节点tree_path + , + 父节点ID,用于删除部门时级联删除子部门 + String treePath = generateDeptTreePath(formData.getParentId()); + entity.setTreePath(treePath); + + // 保存部门并返回部门ID + boolean result = this.updateById(entity); + Assert.isTrue(result, "部门更新失败"); + + return entity.getId(); + } + + /** + * 递归生成部门表格层级列表 + * + * @param parentId 父ID + * @param deptList 部门列表 + * @return 部门表格层级列表 + */ + public static List> recurDeptTreeOptions(long parentId, List deptList) { + return CollectionUtil.emptyIfNull(deptList).stream() + .filter(dept -> dept.getParentId().equals(parentId)) + .map(dept -> { + Option option = new Option<>(dept.getId(), dept.getName()); + List> children = recurDeptTreeOptions(dept.getId(), deptList); + if (CollectionUtil.isNotEmpty(children)) { + option.setChildren(children); + } + return option; + }) + .collect(Collectors.toList()); + } + + + /** + * 删除部门 + * + * @param ids 部门ID,多个以英文逗号,拼接字符串 + * @return 是否删除成功 + */ + @Override + public boolean deleteByIds(String ids) { + // 删除部门及子部门 + if (StrUtil.isNotBlank(ids)) { + String[] menuIds = ids.split(","); + for (String deptId : menuIds) { + String patten = "%," + deptId + ",%"; + this.update(new LambdaUpdateWrapper() + .eq(Dept::getId, deptId) + .or() + .apply("CONCAT (',',tree_path,',') LIKE {0}", patten) + .set(Dept::getIsDeleted, 1) + .set(Dept::getUpdateBy, SecurityUtils.getUserId()) + ); + } + } + return true; + } + + + /** + * 部门路径生成 + * + * @param parentId 父ID + * @return 父节点路径以英文逗号(, )分割,eg: 1,2,3 + */ + private String generateDeptTreePath(Long parentId) { + String treePath = null; + if (SystemConstants.ROOT_NODE_ID.equals(parentId)) { + treePath = String.valueOf(parentId); + } else { + Dept parent = this.getById(parentId); + if (parent != null) { + treePath = parent.getTreePath() + "," + parent.getId(); + } + } + return treePath; + } +} diff --git a/src/main/java/com/youlai/boot/system/service/impl/DictItemServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/DictItemServiceImpl.java new file mode 100644 index 0000000..48c65a2 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/service/impl/DictItemServiceImpl.java @@ -0,0 +1,125 @@ +package com.youlai.boot.system.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.youlai.boot.system.converter.DictItemConverter; +import com.youlai.boot.system.mapper.DictItemMapper; +import com.youlai.boot.system.model.entity.DictItem; +import com.youlai.boot.system.model.form.DictItemForm; +import com.youlai.boot.system.model.query.DictItemQuery; +import com.youlai.boot.system.model.vo.DictItemOptionVO; +import com.youlai.boot.system.model.vo.DictItemPageVO; +import com.youlai.boot.system.service.DictItemService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Arrays; +import java.util.List; + +/** + * 字典项实现类 + * + * @author Ray.Hao + * @since 2022/10/12 + */ +@Service +@RequiredArgsConstructor +public class DictItemServiceImpl extends ServiceImpl implements DictItemService { + + private final DictItemConverter dictItemConverter; + + /** + * 获取字典项分页列表 + * + * @param queryParams 查询参数 + * @return 字典项分页列表 + */ + @Override + public Page getDictItemPage(DictItemQuery queryParams) { + int pageNum = queryParams.getPageNum(); + int pageSize = queryParams.getPageSize(); + Page page = new Page<>(pageNum, pageSize); + + return this.baseMapper.getDictItemPage(page, queryParams); + } + + + /** + * 获取字典项列表 + * + * @param dictCode 字典编码 + */ + @Override + public List getDictItems(String dictCode) { + return this.list( + new LambdaQueryWrapper() + .eq(DictItem::getDictCode, dictCode) + .eq(DictItem::getStatus, 1) + .orderByAsc(DictItem::getSort) + ).stream() + .map(item -> { + DictItemOptionVO dictItemOptionVo = new DictItemOptionVO(); + dictItemOptionVo.setLabel(item.getLabel()); + dictItemOptionVo.setValue(item.getValue()); + dictItemOptionVo.setTagType(item.getTagType()); + return dictItemOptionVo; + }).toList(); + } + + + + /** + * 获取字典项表单 + * + * @param itemId 字典项ID + * @return 字典项表单 + */ + @Override + public DictItemForm getDictItemForm( Long itemId) { + DictItem entity = this.getById(itemId); + return dictItemConverter.toForm(entity); + } + + /** + * 保存字典项 + * + * @param formData 字典项表单 + * @return 是否成功 + */ + @Override + public boolean saveDictItem(DictItemForm formData) { + DictItem entity = dictItemConverter.toEntity(formData); + return this.save(entity); + } + + /** + * 更新字典项 + * + * @param formData 字典项表单 + * @return 是否成功 + */ + @Override + public boolean updateDictItem(DictItemForm formData) { + DictItem entity = dictItemConverter.toEntity(formData); + return this.updateById(entity); + } + + /** + * 删除字典项 + * + * @param ids 字典项ID集合 + */ + @Override + public void deleteDictItemByIds(String ids) { + List idList = Arrays.stream(ids.split(",")) + .map(Long::parseLong) + .toList(); + this.removeByIds(idList); + } + +} + + + + diff --git a/src/main/java/com/youlai/boot/system/service/impl/DictServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/DictServiceImpl.java new file mode 100644 index 0000000..ffc8e06 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/service/impl/DictServiceImpl.java @@ -0,0 +1,187 @@ +package com.youlai.boot.system.service.impl; + +import cn.hutool.core.lang.Assert; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.youlai.boot.common.exception.BusinessException; +import com.youlai.boot.common.model.Option; +import com.youlai.boot.system.converter.DictConverter; +import com.youlai.boot.system.mapper.DictMapper; +import com.youlai.boot.system.model.entity.Dict; +import com.youlai.boot.system.model.entity.DictItem; +import com.youlai.boot.system.model.form.DictForm; +import com.youlai.boot.system.model.query.DictQuery; +import com.youlai.boot.system.model.vo.DictPageVO; +import com.youlai.boot.system.service.DictItemService; +import com.youlai.boot.system.service.DictService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 字典业务实现类 + * + * @author haoxr + * @since 2022/10/12 + */ +@Service +@RequiredArgsConstructor +public class DictServiceImpl extends ServiceImpl implements DictService { + + private final DictItemService dictItemService; + private final DictConverter dictConverter; + + /** + * 字典分页列表 + * + * @param queryParams 分页查询对象 + */ + @Override + public Page getDictPage(DictQuery queryParams) { + // 查询参数 + int pageNum = queryParams.getPageNum(); + int pageSize = queryParams.getPageSize(); + + // 查询数据 + return this.baseMapper.getDictPage(new Page<>(pageNum, pageSize), queryParams); + } + + /** + * 获取字典列表 + * + * @return 字典列表 + */ + @Override + public List> getDictList() { + return this.list(new LambdaQueryWrapper().eq(Dict::getStatus, 1)) + .stream() + .map(item -> new Option<>(item.getDictCode(), item.getName())) + .toList(); + } + + + /** + * 新增字典 + * + * @param dictForm 字典表单数据 + */ + @Override + public boolean saveDict(DictForm dictForm) { + // 保存字典 + Dict entity = dictConverter.toEntity(dictForm); + + // 校验 code 是否唯一 + String dictCode = entity.getDictCode(); + + long count = this.count(new LambdaQueryWrapper() + .eq(Dict::getDictCode, dictCode) + ); + + Assert.isTrue(count == 0, "字典编码已存在"); + + return this.save(entity); + } + + + /** + * 获取字典表单详情 + * + * @param id 字典ID + */ + @Override + public DictForm getDictForm(Long id) { + // 获取字典 + Dict entity = this.getById(id); + if (entity == null) { + throw new BusinessException("字典不存在"); + } + return dictConverter.toForm(entity); + } + + /** + * 修改字典 + * + * @param id 字典ID + * @param dictForm 字典表单 + */ + @Override + @Transactional + public boolean updateDict(Long id, DictForm dictForm) { + // 获取字典 + Dict entity = this.getById(id); + if (entity == null) { + throw new BusinessException("字典不存在"); + } + // 校验 code 是否唯一 + String dictCode = dictForm.getDictCode(); + if (!entity.getDictCode().equals(dictCode)) { + long count = this.count(new LambdaQueryWrapper() + .eq(Dict::getDictCode, dictCode) + ); + Assert.isTrue(count == 0, "字典编码已存在"); + } + // 更新字典 + Dict dict = dictConverter.toEntity(dictForm); + dict.setId(id); + boolean result = this.updateById(dict); + if (result) { + // 更新字典数据 + List dictItemList = dictItemService.list( + new LambdaQueryWrapper() + .eq(DictItem::getDictCode, entity.getDictCode()) + .select(DictItem::getId) + ); + if (!dictItemList.isEmpty()){ + List dictItemIds = dictItemList.stream().map(DictItem::getId).toList(); + DictItem dictItem = new DictItem(); + dictItem.setDictCode(dict.getDictCode()); + dictItemService.update(dictItem, + new LambdaQueryWrapper() + .in(DictItem::getId, dictItemIds) + ); + } + } + return result; + } + + /** + * 删除字典 + * + * @param ids 字典ID,多个以英文逗号(,)分割 + */ + @Transactional + @Override + public void deleteDictByIds(List ids) { + // 删除字典 + this.removeByIds(ids); + + // 删除字典项 + List list = this.listByIds(ids); + if (!list.isEmpty()) { + List dictCodes = list.stream().map(Dict::getDictCode).toList(); + dictItemService.remove(new LambdaQueryWrapper() + .in(DictItem::getDictCode, dictCodes) + ); + } + } + + /** + * 根据字典ID列表获取字典编码列表 + * + * @param ids 字典ID列表 + * @return 字典编码列表 + */ + @Override + public List getDictCodesByIds(List ids) { + List dictList = this.listByIds(ids); + return dictList.stream().map(Dict::getDictCode).toList(); + } + +} + + + + diff --git a/src/main/java/com/youlai/boot/system/service/impl/LogServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/LogServiceImpl.java new file mode 100644 index 0000000..97bd148 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/service/impl/LogServiceImpl.java @@ -0,0 +1,116 @@ +package com.youlai.boot.system.service.impl; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.youlai.boot.system.mapper.LogMapper; +import com.youlai.boot.system.model.dto.VisitCountDTO; +import com.youlai.boot.system.model.vo.VisitOverviewVO; +import com.youlai.boot.system.model.entity.SysLog; +import com.youlai.boot.system.model.query.LogQuery; +import com.youlai.boot.system.model.vo.LogPageVO; +import com.youlai.boot.system.model.vo.VisitOverviewVO; +import com.youlai.boot.system.model.vo.VisitTrendVO; +import com.youlai.boot.system.service.LogService; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 系统日志 服务实现类 + * + * @author Ray.Hao + * @since 2.10.0 + */ +@Service +public class LogServiceImpl extends ServiceImpl + implements LogService { + + /** + * 获取日志分页列表 + * + * @param queryParams 查询参数 + * @return 日志分页列表 + */ + @Override + public Page getLogPage(LogQuery queryParams) { + return this.baseMapper.getLogPage(new Page<>(queryParams.getPageNum(), queryParams.getPageSize()), + queryParams); + } + + /** + * 获取访问趋势 + * + * @param startDate 开始时间 + * @param endDate 结束时间 + * @return + */ + @Override + public VisitTrendVO getVisitTrend(LocalDate startDate, LocalDate endDate) { + VisitTrendVO visitTrend = new VisitTrendVO(); + List dates = new ArrayList<>(); + + // 获取日期范围内的日期 + while (!startDate.isAfter(endDate)) { + dates.add(startDate.toString()); + startDate = startDate.plusDays(1); + } + visitTrend.setDates(dates); + + // 获取访问量和访问 IP 数的统计数据 + List pvCounts = this.baseMapper.getPvCounts(dates.get(0) + " 00:00:00", dates.get(dates.size() - 1) + " 23:59:59"); + List ipCounts = this.baseMapper.getIpCounts(dates.get(0) + " 00:00:00", dates.get(dates.size() - 1) + " 23:59:59"); + + // 将统计数据转换为 Map + Map pvMap = pvCounts.stream().collect(Collectors.toMap(VisitCountDTO::getDate, VisitCountDTO::getCount)); + Map uvMap = ipCounts.stream().collect(Collectors.toMap(VisitCountDTO::getDate, VisitCountDTO::getCount)); + + // 匹配日期和访问量/访客数 + List pvList = new ArrayList<>(); + List uvList = new ArrayList<>(); + + for (String date : dates) { + pvList.add(pvMap.getOrDefault(date, 0)); + uvList.add(uvMap.getOrDefault(date, 0)); + } + + visitTrend.setPvList(pvList); + visitTrend.setUvList(uvList); + + return visitTrend; + } + + /** + * 访问量统计 + */ + @Override + public VisitOverviewVO getVisitStats() { + VisitOverviewVO result = new VisitOverviewVO(); + + // 访客数统计(UV) + VisitOverviewVO uvStats = this.baseMapper.getUvStats(); + if(uvStats!=null){ + result.setTodayUvCount(uvStats.getTodayUvCount()); + result.setTotalUvCount(uvStats.getTotalUvCount()); + result.setUvGrowthRate(uvStats.getUvGrowthRate()); + } + + // 浏览量统计(PV) + VisitOverviewVO pvStats = this.baseMapper.getPvStats(); + if(pvStats!=null){ + result.setTodayPvCount(pvStats.getTodayPvCount()); + result.setTotalPvCount(pvStats.getTotalPvCount()); + result.setPvGrowthRate(pvStats.getPvGrowthRate()); + } + + return result; + } + +} + + + + diff --git a/src/main/java/com/youlai/boot/system/service/impl/MenuServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/MenuServiceImpl.java new file mode 100644 index 0000000..088ae04 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/service/impl/MenuServiceImpl.java @@ -0,0 +1,494 @@ +package com.youlai.boot.system.service.impl; + +import cn.hutool.core.collection.CollectionUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.youlai.boot.codegen.model.entity.GenTable; +import com.youlai.boot.framework.security.util.SecurityUtils; +import com.youlai.boot.system.converter.MenuConverter; +import com.youlai.boot.system.mapper.MenuMapper; +import com.youlai.boot.system.model.entity.Menu; +import com.youlai.boot.system.model.form.MenuForm; +import com.youlai.boot.system.model.query.MenuQuery; +import com.youlai.boot.system.model.vo.MenuVO; +import com.youlai.boot.system.model.vo.RouteVO; +import com.youlai.boot.common.constant.SystemConstants; +import com.youlai.boot.system.enums.MenuTypeEnum; +import com.youlai.boot.common.enums.StatusEnum; +import com.youlai.boot.common.model.KeyValue; +import com.youlai.boot.common.model.Option; +import com.youlai.boot.system.service.MenuService; +import com.youlai.boot.system.service.RoleMenuService; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 菜单服务实现类 + * + * @author Ray.Hao + * @since 2020/11/06 + */ +@Service +@RequiredArgsConstructor +public class MenuServiceImpl extends ServiceImpl implements MenuService { + + private final MenuConverter menuConverter; + + private final RoleMenuService roleMenuService; + + + /** + * 菜单列表 + * + * @param queryParams {@link MenuQuery} + */ + @Override + public List listMenus(MenuQuery queryParams) { + List

menus = this.list(new LambdaQueryWrapper() + .like(StrUtil.isNotBlank(queryParams.getKeywords()), Menu::getName, queryParams.getKeywords()) + .orderByAsc(Menu::getSort) + ); + // 获取所有菜单ID + Set menuIds = menus.stream() + .map(Menu::getId) + .collect(Collectors.toSet()); + + // 获取所有父级ID + Set parentIds = menus.stream() + .map(Menu::getParentId) + .collect(Collectors.toSet()); + + // 获取根节点ID(递归的起点),即父节点ID中不包含在部门ID中的节点,注意这里不能拿顶级菜单 O 作为根节点,因为菜单筛选的时候 O 会被过滤掉 + List rootIds = parentIds.stream() + .filter(id -> !menuIds.contains(id)) + .toList(); + + // 使用递归函数来构建菜单树 + return rootIds.stream() + .flatMap(rootId -> buildMenuTree(rootId, menus).stream()) + .collect(Collectors.toList()); + } + + /** + * 递归生成菜单列表 + * + * @param parentId 父级ID + * @param menuList 菜单列表 + * @return 菜单列表 + */ + private List buildMenuTree(Long parentId, List menuList) { + return CollectionUtil.emptyIfNull(menuList) + .stream() + .filter(menu -> menu.getParentId().equals(parentId)) + .map(entity -> { + MenuVO menuVo = menuConverter.toVo(entity); + List children = buildMenuTree(entity.getId(), menuList); + menuVo.setChildren(children); + return menuVo; + }).toList(); + } + + /** + * 菜单下拉数据 + * + * @param onlyParent 是否只查询父级菜单 如果为true,排除按钮 + */ + @Override + public List> listMenuOptions(boolean onlyParent) { + List menuList = this.list(new LambdaQueryWrapper() + .in(onlyParent, Menu::getType, MenuTypeEnum.CATALOG.getValue(), MenuTypeEnum.MENU.getValue()) + .orderByAsc(Menu::getSort) + ); + return buildMenuOptions(SystemConstants.ROOT_NODE_ID, menuList); + } + + /** + * 递归生成菜单下拉层级列表 + * + * @param parentId 父级ID + * @param menuList 菜单列表 + * @return 菜单下拉列表 + */ + private List> buildMenuOptions(Long parentId, List menuList) { + List> menuOptions = new ArrayList<>(); + + for (Menu menu : menuList) { + if (menu.getParentId().equals(parentId)) { + Option option = new Option<>(menu.getId(), menu.getName()); + List> subMenuOptions = buildMenuOptions(menu.getId(), menuList); + if (!subMenuOptions.isEmpty()) { + option.setChildren(subMenuOptions); + } + menuOptions.add(option); + } + } + + return menuOptions; + } + + /** + * 获取当前用户的菜单路由列表 + */ + @Override + public List listCurrentUserRoutes() { + Set roleCodes = SecurityUtils.getRoles(); + + if (CollectionUtil.isEmpty(roleCodes)) { + return Collections.emptyList(); + } + + List menuList; + if (SecurityUtils.isRoot()) { + // 超级管理员获取所有菜单 + menuList = this.list(new LambdaQueryWrapper() + .ne(Menu::getType, MenuTypeEnum.BUTTON.getValue()) + .orderByAsc(Menu::getSort) + ); + } else { + // 普通用户:通过角色获取菜单(权限控制已过滤) + menuList = this.baseMapper.getMenusByRoleCodes(roleCodes); + + // 双重保障:动态查询"平台管理"目录,过滤其子菜单 + // 通过路由路径识别平台管理目录,避免硬编码 + Menu platformMenu = this.getOne(new LambdaQueryWrapper() + .eq(Menu::getRoutePath, "/support") + .eq(Menu::getParentId, SystemConstants.ROOT_NODE_ID) + .eq(Menu::getType, MenuTypeEnum.CATALOG.getValue()) + .last("LIMIT 1") + ); + + if (platformMenu != null) { + final Long platformMenuId = platformMenu.getId(); + menuList = menuList.stream() + .filter(menu -> { + String treePath = menu.getTreePath(); + // 排除平台管理目录及其子菜单 + // treePath 格式:0,1 或 0,1,110 等 + return treePath == null || + (!treePath.startsWith("0," + platformMenuId + ",") && + !treePath.equals("0," + platformMenuId)); + }) + .collect(Collectors.toList()); + } + } + return buildRoutes(SystemConstants.ROOT_NODE_ID, menuList); + } + + /** + * 获取当前用户的菜单路由列表(指定数据源) + * + * @param datasource 数据源名称 + * - master: 主库菜单数据 + * - naiveui: NaiveUI项目菜单数据 + * - template: 模板项目菜单数据 + */ + @Override + public List listCurrentUserRoutes(String datasource) { + return listCurrentUserRoutes(); + } + + + /** + * 递归生成菜单路由层级列表 + * + * @param parentId 父级ID + * @param menuList 菜单列表 + * @return 路由层级列表 + */ + private List buildRoutes(Long parentId, List menuList) { + List routeList = new ArrayList<>(); + + for (Menu menu : menuList) { + if (menu.getParentId().equals(parentId)) { + RouteVO routeVo = toRouteVo(menu); + List children = buildRoutes(menu.getId(), menuList); + if (!children.isEmpty()) { + routeVo.setChildren(children); + } + routeList.add(routeVo); + } + } + + return routeList; + } + + /** + * 根据RouteBO创建RouteVo + */ + private RouteVO toRouteVo(Menu menu) { + RouteVO routeVo = new RouteVO(); + String routePath = menu.getRoutePath(); + boolean externalLink = StrUtil.startWithAny(routePath, "http://", "https://"); + + // 获取路由名称 + String routeName = menu.getRouteName(); + if (StrUtil.isBlank(routeName)) { + // 外链不做驼峰转换,使用唯一占位,避免 http:// 被解析异常 + routeName = externalLink + ? "ext-" + menu.getId() + : StringUtils.capitalize(StrUtil.toCamelCase(routePath, '-')); + } + // 根据name路由跳转 this.$router.push({name:xxx}) + routeVo.setName(routeName); + + // 根据path路由跳转 this.$router.push({path:xxx}) + routeVo.setPath(routePath); + routeVo.setRedirect(menu.getRedirect()); + // 外链无组件 + routeVo.setComponent(externalLink ? null : menu.getComponent()); + + RouteVO.Meta meta = new RouteVO.Meta(); + meta.setTitle(menu.getName()); + meta.setIcon(menu.getIcon()); + meta.setHidden(StatusEnum.DISABLE.getValue().equals(menu.getVisible())); + // 【菜单】是否开启页面缓存 + if (MenuTypeEnum.MENU.getValue().equals(menu.getType()) + && ObjectUtil.equals(menu.getKeepAlive(), 1)) { + meta.setKeepAlive(true); + } + meta.setAlwaysShow(ObjectUtil.equals(menu.getAlwaysShow(), 1)); + + Map paramsMap = menu.getParams(); + if (paramsMap != null && !paramsMap.isEmpty()) { + Map paramMap = paramsMap.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> String.valueOf(e.getValue()))); + meta.setParams(paramMap); + } + routeVo.setMeta(meta); + return routeVo; + } + + /** + * 新增/修改菜单 + */ + @Override + @CacheEvict(cacheNames = "menu", key = "'routes'") + public boolean saveMenu(MenuForm menuForm) { + + String menuType = menuForm.getType(); + boolean isExternalLink = MenuTypeEnum.MENU.getValue().equals(menuType) + && StrUtil.startWithAny(menuForm.getRoutePath(), "http://", "https://"); + + if (MenuTypeEnum.CATALOG.getValue().equals(menuType)) { // 如果是目录 + String path = menuForm.getRoutePath(); + if (menuForm.getParentId() == 0 && !path.startsWith("/")) { + menuForm.setRoutePath("/" + path); // 一级目录需以 / 开头 + } + menuForm.setComponent("Layout"); + } else if (isExternalLink) { + // 外链菜单组件设置为 null,通过 routePath 判断外链 + menuForm.setComponent(null); + } + if (Objects.equals(menuForm.getParentId(), menuForm.getId())) { + throw new RuntimeException("父级菜单不能为当前菜单"); + } + Menu entity = menuConverter.toEntity(menuForm); + String treePath = generateMenuTreePath(menuForm.getParentId()); + entity.setTreePath(treePath); + + List params = menuForm.getParams(); + if (CollectionUtil.isNotEmpty(params)) { + entity.setParams(params.stream() + .collect(Collectors.toMap(KeyValue::getKey, KeyValue::getValue))); + } else { + entity.setParams(null); + } + // 新增类型为菜单时候 路由名称唯一 + if (MenuTypeEnum.MENU.getValue().equals(menuType) && !isExternalLink) { + Assert.isFalse(this.exists(new LambdaQueryWrapper() + .eq(Menu::getRouteName, entity.getRouteName()) + .ne(menuForm.getId() != null, Menu::getId, menuForm.getId()) + ), "路由名称已存在"); + } else { + // 其他类型时 给路由名称赋值为空 + entity.setRouteName(null); + } + + boolean result = this.saveOrUpdate(entity); + if (result) { + // 编辑刷新角色权限缓存 + if (menuForm.getId() != null) { + roleMenuService.refreshRolePermsCache(); + } + } + // 修改菜单如果有子菜单,则更新子菜单的树路径 + updateChildrenTreePath(entity.getId(), treePath); + return result; + } + + /** + * 更新子菜单树路径 + * + * @param id 当前菜单ID + * @param treePath 当前菜单树路径 + */ + private void updateChildrenTreePath(Long id, String treePath) { + List children = this.list(new LambdaQueryWrapper().eq(Menu::getParentId, id)); + if (CollectionUtil.isNotEmpty(children)) { + // 子菜单的树路径等于父菜单的树路径加上父菜单ID + String childTreePath = treePath + "," + id; + this.update(new LambdaUpdateWrapper() + .eq(Menu::getParentId, id) + .set(Menu::getTreePath, childTreePath) + ); + for (Menu child : children) { + // 递归更新子菜单 + updateChildrenTreePath(child.getId(), childTreePath); + } + } + } + + /** + * 部门路径生成 + * + * @param parentId 父ID + * @return 父节点路径以英文逗号(, )分割,eg: 1,2,3 + */ + private String generateMenuTreePath(Long parentId) { + if (SystemConstants.ROOT_NODE_ID.equals(parentId)) { + return String.valueOf(parentId); + } else { + Menu parent = this.getById(parentId); + return parent != null ? parent.getTreePath() + "," + parent.getId() : null; + } + } + + + /** + * 修改菜单显示状态 + * + * @param menuId 菜单ID + * @param visible 是否显示(1->显示;2->隐藏) + * @return 是否修改成功 + */ + @Override + @CacheEvict(cacheNames = "menu", key = "'routes'") + public boolean updateMenuVisible(Long menuId, Integer visible) { + return this.update(new LambdaUpdateWrapper() + .eq(Menu::getId, menuId) + .set(Menu::getVisible, visible) + ); + } + + /** + * 获取菜单表单数据 + * + * @param id 菜单ID + * @return 菜单表单数据 + */ + @Override + public MenuForm getMenuForm(Long id) { + Menu entity = this.getById(id); + Assert.isTrue(entity != null, "菜单不存在"); + MenuForm formData = menuConverter.toForm(entity); + // 路由参数 Map 转换为 [{key:"id", value:"1"}, {key:"name", value:"张三"}] + Map paramsMap = entity.getParams(); + if (paramsMap != null && !paramsMap.isEmpty()) { + List transformedList = paramsMap.entrySet().stream() + .map(entry -> new KeyValue(entry.getKey(), String.valueOf(entry.getValue()))) + .toList(); + formData.setParams(transformedList); + } + return formData; + } + + /** + * 删除菜单 + * + * @param id 菜单ID + * @return 是否删除成功 + */ + @Override + @CacheEvict(cacheNames = "menu", key = "'routes'") + public boolean deleteMenu(Long id) { + boolean result = this.remove(new LambdaQueryWrapper() + .eq(Menu::getId, id) + .or() + .apply("CONCAT (',',tree_path,',') LIKE CONCAT('%,',{0},',%')", id)); + + + // 刷新角色权限缓存 + if (result) { + roleMenuService.refreshRolePermsCache(); + } + return result; + + } + + /** + * 代码生成时添加菜单 + * + * @param parentMenuId 父菜单ID + * @param genTable 实体名称 + */ + @Override + public void addMenuForCodegen(Long parentMenuId, GenTable genTable) { + Menu parentMenu = this.getById(parentMenuId); + Assert.notNull(parentMenu, "上级菜单不存在"); + + String entityName = genTable.getEntityName(); + + long count = this.count(new LambdaQueryWrapper().eq(Menu::getRouteName, entityName)); + if (count > 0) { + return; + } + + // 获取父级菜单子菜单最带的排序 + Menu maxSortMenu = this.getOne(new LambdaQueryWrapper().eq(Menu::getParentId, parentMenuId) + .orderByDesc(Menu::getSort) + .last("limit 1") + ); + int sort = 1; + if (maxSortMenu != null) { + sort = maxSortMenu.getSort() + 1; + } + + Menu menu = new Menu(); + menu.setParentId(parentMenuId); + menu.setName(genTable.getBusinessName()); + + menu.setRouteName(entityName); + menu.setRoutePath(StrUtil.toSymbolCase(entityName, '-')); + menu.setComponent(genTable.getModuleName() + "/" + StrUtil.toSymbolCase(entityName, '-') + "/index"); + menu.setType(MenuTypeEnum.MENU.getValue()); + menu.setSort(sort); + menu.setVisible(1); + boolean result = this.save(menu); + + if (result) { + // 生成treePath + String treePath = generateMenuTreePath(parentMenuId); + menu.setTreePath(treePath); + this.updateById(menu); + + // 生成CURD按钮权限 + String permPrefix = genTable.getModuleName() + ":" + genTable.getTableName().replace("_", "-") + ":"; + String[] actions = {"查询", "新增", "修改", "删除"}; + String[] perms = {"list", "create", "update", "delete"}; + + for (int i = 0; i < actions.length; i++) { + Menu button = new Menu(); + button.setParentId(menu.getId()); + button.setType(MenuTypeEnum.BUTTON.getValue()); + button.setName(actions[i]); + button.setPerm(permPrefix + perms[i]); + button.setSort(i + 1); + this.save(button); + + // 生成treePath + button.setTreePath(treePath + "," + button.getId()); + this.updateById(button); + } + } + } + +} 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 new file mode 100644 index 0000000..5052c03 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/service/impl/NoticeServiceImpl.java @@ -0,0 +1,316 @@ +package com.youlai.boot.system.service.impl; + +import cn.hutool.core.collection.CollectionUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.youlai.boot.common.exception.BusinessException; +import com.youlai.boot.message.dto.OnlineUserDTO; +import com.youlai.boot.framework.security.util.SecurityUtils; +import com.youlai.boot.system.converter.NoticeConverter; +import com.youlai.boot.system.enums.NoticePublishStatusEnum; +import com.youlai.boot.system.enums.NoticeTargetEnum; +import com.youlai.boot.system.mapper.NoticeMapper; +import com.youlai.boot.system.model.vo.NoticePageVO; +import com.youlai.boot.system.model.vo.UserNoticePageVO; +import com.youlai.boot.system.model.vo.NoticeDetailVO; +import com.youlai.boot.system.model.vo.NoticeVO; +import com.youlai.boot.system.model.entity.Notice; +import com.youlai.boot.system.model.entity.UserNotice; +import com.youlai.boot.system.model.entity.SysUser; +import com.youlai.boot.system.model.form.NoticeForm; +import com.youlai.boot.system.model.query.NoticeQuery; +import com.youlai.boot.system.service.NoticeService; +import com.youlai.boot.system.service.UserNoticeService; +import com.youlai.boot.system.service.UserService; +import com.youlai.boot.message.service.SseService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 通知公告服务实现类 + * + * @author Theo + * @since 2024-08-27 10:31 + */ +@Service +@RequiredArgsConstructor +public class NoticeServiceImpl extends ServiceImpl implements NoticeService { + + private final NoticeConverter noticeConverter; + private final UserNoticeService userNoticeService; + private final UserService userService; + private final SseService sseService; + + /** + * 获取通知公告分页列表 + * + * @param queryParams 查询参数 + * @return {@link IPage< NoticePageVO >} 通知公告分页列表 + */ + @Override + public IPage getNoticePage(NoticeQuery queryParams) { + return this.baseMapper.getNoticePage( + new Page<>(queryParams.getPageNum(), queryParams.getPageSize()), + queryParams + ); + } + + /** + * 获取通知公告表单数据 + * + * @param id 通知公告ID + * @return {@link NoticeForm} 通知公告表单对象 + */ + @Override + public NoticeForm getNoticeFormData(Long id) { + Notice entity = this.getById(id); + return noticeConverter.toForm(entity); + } + + /** + * 新增通知公告 + * + * @param formData 通知公告表单对象 + * @return {@link Boolean} 是否新增成功 + */ + @Override + public boolean saveNotice(NoticeForm formData) { + + if (NoticeTargetEnum.SPECIFIED.getValue().equals(formData.getTargetType())) { + List targetUserIdList = formData.getTargetUserIds(); + if (CollectionUtil.isEmpty(targetUserIdList)) { + throw new BusinessException("推送指定用户不能为空"); + } + } + Notice entity = noticeConverter.toEntity(formData); + entity.setCreateBy(SecurityUtils.getUserId()); + return this.save(entity); + } + + /** + * 更新通知公告 + * + * @param id 通知公告ID + * @param formData 通知公告表单对象 + * @return {@link Boolean} 是否更新成功 + */ + @Override + public boolean updateNotice(Long id, NoticeForm formData) { + if (NoticeTargetEnum.SPECIFIED.getValue().equals(formData.getTargetType())) { + List targetUserIdList = formData.getTargetUserIds(); + if (CollectionUtil.isEmpty(targetUserIdList)) { + throw new BusinessException("推送指定用户不能为空"); + } + } + + Notice entity = noticeConverter.toEntity(formData); + return this.updateById(entity); + } + + /** + * 删除通知公告 + * + * @param ids 通知公告ID,多个以英文逗号(,)分割 + * @return {@link Boolean} 是否删除成功 + */ + @Override + @Transactional + public boolean deleteNotices(String ids) { + if (StrUtil.isBlank(ids)) { + throw new BusinessException("删除的通知公告数据为空"); + } + + // 逻辑删除 + List idList = Arrays.stream(ids.split(",")) + .map(Long::parseLong) + .toList(); + boolean isRemoved = this.removeByIds(idList); + if (isRemoved) { + // 删除通知公告的同时,需要删除通知公告对应的用户通知状态 + userNoticeService.remove(new LambdaQueryWrapper().in(UserNotice::getNoticeId, idList)); + } + return isRemoved; + } + + /** + * 发布通知公告 + * + * @param id 通知公告ID + * @return 是否发布成功 + */ + @Override + @Transactional + public boolean publishNotice(Long id) { + Notice notice = this.getById(id); + if (notice == null) { + throw new BusinessException("通知公告不存在"); + } + + if (NoticePublishStatusEnum.PUBLISHED.getValue().equals(notice.getPublishStatus())) { + throw new BusinessException("通知公告已发布"); + } + + Integer targetType = notice.getTargetType(); + String targetUserIds = notice.getTargetUserIds(); + if (NoticeTargetEnum.SPECIFIED.getValue().equals(targetType) + && StrUtil.isBlank(targetUserIds)) { + throw new BusinessException("推送指定用户不能为空"); + } + + notice.setPublishStatus(NoticePublishStatusEnum.PUBLISHED.getValue()); + notice.setPublisherId(SecurityUtils.getUserId()); + notice.setPublishTime(LocalDateTime.now()); + boolean publishResult = this.updateById(notice); + + if (publishResult) { + // 发布通知公告的同时,删除该通告之前的用户通知数据,因为可能是重新发布 + userNoticeService.remove( + new LambdaQueryWrapper().eq(UserNotice::getNoticeId, id) + ); + + // 添加新的用户通知数据 + List targetUserIdList = null; + if (NoticeTargetEnum.SPECIFIED.getValue().equals(targetType)) { + targetUserIdList = Arrays.asList(targetUserIds.split(",")); + } + + List targetUserList = userService.list( + new LambdaQueryWrapper() + // 如果是指定用户,则筛选出指定用户 + .in( + NoticeTargetEnum.SPECIFIED.getValue().equals(targetType), + SysUser::getId, + targetUserIdList + ) + ); + + List userNoticeList = targetUserList.stream().map(user -> { + UserNotice userNotice = new UserNotice(); + userNotice.setNoticeId(id); + userNotice.setUserId(user.getId()); + userNotice.setIsRead(0); + return userNotice; + }).toList(); + + if (CollectionUtil.isNotEmpty(userNoticeList)) { + userNoticeService.saveBatch(userNoticeList); + } + + Set receivers = targetUserList.stream().map(SysUser::getUsername).collect(Collectors.toSet()); + + // 获取在线用户名集合 + Set allOnlineUsers = sseService.getOnlineUsers().stream() + .map(OnlineUserDTO::getUsername) + .collect(Collectors.toSet()); + + // 找出在线用户的通知接收者 + Set onlineReceivers = new HashSet<>(CollectionUtil.intersection(receivers, allOnlineUsers)); + + NoticeVO noticeVo = new NoticeVO(); + noticeVo.setId(id); + noticeVo.setTitle(notice.getTitle()); + noticeVo.setType(notice.getType()); + noticeVo.setPublishTime(notice.getPublishTime()); + + // 向在线接收者推送通知 + onlineReceivers.forEach(receiver -> sseService.sendToUser(receiver, "notice", noticeVo)); + } + return publishResult; + } + + /** + * 撤回通知公告 + * + * @param id 通知公告ID + * @return 是否撤回成功 + */ + @Override + @Transactional + public boolean revokeNotice(Long id) { + Notice notice = this.getById(id); + if (notice == null) { + throw new BusinessException("通知公告不存在"); + } + + if (!NoticePublishStatusEnum.PUBLISHED.getValue().equals(notice.getPublishStatus())) { + throw new BusinessException("通知公告未发布或已撤回"); + } + + notice.setPublishStatus(NoticePublishStatusEnum.REVOKED.getValue()); + notice.setRevokeTime(LocalDateTime.now()); + notice.setUpdateBy(SecurityUtils.getUserId()); + + boolean revokeResult = this.updateById(notice); + + if (revokeResult) { + // 撤回通知公告的同时,需要删除通知公告对应的用户通知状态 + userNoticeService.remove(new LambdaQueryWrapper() + .eq(UserNotice::getNoticeId, id) + ); + + // 通知前端移除该通知 + NoticeVO noticeVo = new NoticeVO(); + noticeVo.setId(id); + + // 获取所有在线用户 + Set allOnlineUsers = sseService.getOnlineUsers().stream() + .map(OnlineUserDTO::getUsername) + .collect(Collectors.toSet()); + + // 向所有在线用户推送撤回通知 + allOnlineUsers.forEach(username -> + sseService.sendToUser(username, "notice-revoke", noticeVo)); + } + return revokeResult; + } + + /** + * + * @param id 通知公告ID + * @return NoticeDetailVO 通知公告详情 + */ + @Override + public NoticeDetailVO getNoticeDetail(Long id) { + NoticeDetailVO detail = this.baseMapper.getNoticeDetail(id); + // 更新用户通知公告的阅读状态 + Long userId = SecurityUtils.getUserId(); + userNoticeService.update(new LambdaUpdateWrapper() + .eq(UserNotice::getNoticeId, id) + .eq(UserNotice::getUserId, userId) + .eq(UserNotice::getIsRead, 0) + .set(UserNotice::getIsRead, 1) + ); + return detail; + } + + /** + * 获取当前登录用户的通知公告列表 + * + * @param queryParams 查询参数 + * @return 通知公告分页列表 + */ + @Override + public IPage getMyNoticePage( + NoticeQuery queryParams + ) { + Long userId = SecurityUtils.getUserId(); + queryParams.setUserId(userId); + return userNoticeService.getMyNoticePage( + new Page<>(queryParams.getPageNum(), queryParams.getPageSize()), + queryParams + ); + } + +} diff --git a/src/main/java/com/youlai/boot/system/service/impl/RoleDeptServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/RoleDeptServiceImpl.java new file mode 100644 index 0000000..c6240b4 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/service/impl/RoleDeptServiceImpl.java @@ -0,0 +1,65 @@ +package com.youlai.boot.system.service.impl; + +import cn.hutool.core.collection.CollectionUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.youlai.boot.system.mapper.RoleDeptMapper; +import com.youlai.boot.system.model.entity.RoleDept; +import com.youlai.boot.system.service.RoleDeptService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.List; + +/** + * 角色部门关联服务实现 + * + * @author Ray.Hao + * @since 3.0.0 + */ +@Service +@RequiredArgsConstructor +public class RoleDeptServiceImpl extends ServiceImpl implements RoleDeptService { + + @Override + public List getDeptIdsByRoleId(Long roleId) { + if (roleId == null) { + return Collections.emptyList(); + } + return this.baseMapper.getDeptIdsByRoleId(roleId); + } + + @Override + public List getDeptIdsByRoleCodes(List roleCodes) { + if (CollectionUtil.isEmpty(roleCodes)) { + return Collections.emptyList(); + } + return this.baseMapper.getDeptIdsByRoleCodes(roleCodes); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void saveRoleDepts(Long roleId, List deptIds) { + if (roleId == null || CollectionUtil.isEmpty(deptIds)) { + return; + } + // 先删除原有关联 + this.remove(new LambdaQueryWrapper().eq(RoleDept::getRoleId, roleId)); + // 批量插入新关联 + List roleDepts = deptIds.stream() + .map(deptId -> new RoleDept(roleId, deptId)) + .toList(); + this.saveBatch(roleDepts); + } + + @Override + public void deleteByRoleId(Long roleId) { + if (roleId == null) { + return; + } + this.remove(new LambdaQueryWrapper().eq(RoleDept::getRoleId, roleId)); + } + +} diff --git a/src/main/java/com/youlai/boot/system/service/impl/RoleMenuServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/RoleMenuServiceImpl.java new file mode 100644 index 0000000..33c5822 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/service/impl/RoleMenuServiceImpl.java @@ -0,0 +1,182 @@ +package com.youlai.boot.system.service.impl; + +import cn.hutool.core.collection.CollectionUtil; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.youlai.boot.common.constant.RedisConstants; +import com.youlai.boot.system.mapper.RoleMenuMapper; +import com.youlai.boot.system.model.dto.RolePermsDTO; +import com.youlai.boot.system.model.entity.RoleMenu; +import com.youlai.boot.system.service.RoleMenuService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 角色菜单服务实现类 + * + * @author Ray.Hao + * @since 2.5.0 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class RoleMenuServiceImpl extends ServiceImpl implements RoleMenuService { + + private final RedisTemplate redisTemplate; + + /** + * 刷新权限缓存 + */ + @Override + public void refreshRolePermsCache() { + String cacheKey = RedisConstants.System.ROLE_PERMS; + + // 清理权限缓存 + redisTemplate.delete(cacheKey); + + // 预热权限缓存,避免后续请求触发频繁回源 + List list = this.baseMapper.getRolePermsList(null); + if (CollectionUtil.isNotEmpty(list)) { + list.forEach(item -> { + String roleCode = item.getRoleCode(); + Set perms = item.getPerms(); + if (CollectionUtil.isNotEmpty(perms)) { + redisTemplate.opsForHash().put(cacheKey, roleCode, perms); + } + }); + } + + log.info("权限缓存刷新完成"); + } + + /** + * 刷新单个角色权限缓存 + */ + @Override + public void refreshRolePermsCache(String roleCode) { + String cacheKey = RedisConstants.System.ROLE_PERMS; + + // 清理指定角色缓存 + redisTemplate.opsForHash().delete(cacheKey, roleCode); + + // 回源 DB 并更新缓存 + List list = this.baseMapper.getRolePermsList(roleCode); + if (CollectionUtil.isNotEmpty(list)) { + RolePermsDTO rolePerms = list.get(0); + if (rolePerms != null) { + Set perms = rolePerms.getPerms(); + if (CollectionUtil.isNotEmpty(perms)) { + redisTemplate.opsForHash().put(cacheKey, roleCode, perms); + } + } + } + + log.info("角色[{}]权限缓存刷新完成", roleCode); + } + + /** + * 刷新权限缓存(角色编码变更时调用) + */ + @Override + public void refreshRolePermsCache(String oldRoleCode, String newRoleCode) { + String cacheKey = RedisConstants.System.ROLE_PERMS; + + // 清理旧角色权限缓存 + redisTemplate.opsForHash().delete(cacheKey, oldRoleCode); + redisTemplate.opsForHash().delete(cacheKey, newRoleCode); + + // 回源 DB 并更新新角色编码缓存 + List list = this.baseMapper.getRolePermsList(newRoleCode); + if (CollectionUtil.isNotEmpty(list)) { + RolePermsDTO rolePerms = list.get(0); + if (rolePerms != null) { + Set perms = rolePerms.getPerms(); + if (CollectionUtil.isNotEmpty(perms)) { + redisTemplate.opsForHash().put(cacheKey, newRoleCode, perms); + } + } + } + + log.info("角色编码变更: {} -> {},相关权限缓存刷新完成", oldRoleCode, newRoleCode); + } + + /** + * 获取角色权限集合(带缓存) + *

+ * 采用 Read-Through 缓存策略: + *

    + *
  1. 优先从 Redis Hash 缓存读取
  2. + *
  3. 缓存未命中时回源 DB 并写入缓存
  4. + *
+ * + * @param roleCodes 角色编码集合 + * @return 权限集合 + */ + @Override + public Set getRolePermsByRoleCodes(Set roleCodes) { + if (CollectionUtil.isEmpty(roleCodes)) { + return Collections.emptySet(); + } + + String cacheKey = RedisConstants.System.ROLE_PERMS; + Set perms = new HashSet<>(); + List roleCodeList = new ArrayList<>(roleCodes); + + // 1. 尝试从缓存批量获取 + List cachedPermsList = redisTemplate.opsForHash().multiGet(cacheKey, new ArrayList<>(roleCodeList)); + + List missingRoles = new ArrayList<>(); + for (int i = 0; i < roleCodeList.size(); i++) { + Object cachedPerms = cachedPermsList.get(i); + String roleCode = roleCodeList.get(i); + + if (cachedPerms == null) { + // 缓存未命中,记录需要回源的角色 + missingRoles.add(roleCode); + continue; + } + + // Redis JSON 序列化后,Set 会以 Collection 形式反序列化 + if (cachedPerms instanceof Collection collection) { + collection.stream() + .filter(Objects::nonNull) + .map(Object::toString) + .forEach(perms::add); + } else { + // 兼容单个权限字符串的极端情况 + perms.add(cachedPerms.toString()); + } + } + + // 2. 回源 DB 并同步到缓存 + if (!missingRoles.isEmpty()) { + for (String roleCode : missingRoles) { + Set dbPerms = this.baseMapper.listRolePerms(Collections.singleton(roleCode)); + if (dbPerms == null) { + dbPerms = Collections.emptySet(); + } + // 写入缓存(空集也写入,防止缓存穿透) + redisTemplate.opsForHash().put(cacheKey, roleCode, dbPerms); + perms.addAll(dbPerms); + } + } + + return perms; + } + + /** + * 获取角色拥有的菜单ID集合 + * + * @param roleId 角色ID + * @return 菜单ID集合 + */ + @Override + public List listMenuIdsByRoleId(Long roleId) { + return this.baseMapper.listMenuIdsByRoleId(roleId); + } + +} diff --git a/src/main/java/com/youlai/boot/system/service/impl/RoleServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/RoleServiceImpl.java new file mode 100644 index 0000000..de6e542 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/service/impl/RoleServiceImpl.java @@ -0,0 +1,371 @@ +package com.youlai.boot.system.service.impl; + +import cn.hutool.core.collection.CollectionUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import com.youlai.boot.framework.security.token.TokenManager; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.youlai.boot.common.constant.SystemConstants; +import com.youlai.boot.common.enums.DataScopeEnum; +import com.youlai.boot.common.exception.BusinessException; +import com.youlai.boot.framework.security.model.RoleDataScope; +import com.youlai.boot.system.converter.RoleConverter; +import com.youlai.boot.system.mapper.RoleMapper; +import com.youlai.boot.system.model.entity.Role; +import com.youlai.boot.system.model.entity.RoleMenu; +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.model.Option; +import com.youlai.boot.framework.security.util.SecurityUtils; +import com.youlai.boot.system.service.RoleDeptService; +import com.youlai.boot.system.service.RoleMenuService; +import com.youlai.boot.system.service.RoleService; +import com.youlai.boot.system.service.UserRoleService; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 角色业务实现类 + * + * @author haoxr + * @since 2022/6/3 + */ +@Service +@RequiredArgsConstructor +public class RoleServiceImpl extends ServiceImpl implements RoleService { + + private final RoleMenuService roleMenuService; + private final RoleDeptService roleDeptService; + private final UserRoleService userRoleService; + private final TokenManager tokenManager; + private final RoleConverter roleConverter; + + /** + * 角色分页列表 + * + * @param queryParams 角色查询参数 + * @return {@link Page< RolePageVO >} – 角色分页列表 + */ + @Override + public Page getRolePage(RoleQuery queryParams) { + // 查询参数 + int pageNum = queryParams.getPageNum(); + int pageSize = queryParams.getPageSize(); + String keywords = queryParams.getKeywords(); + + // 查询数据 + Page rolePage = this.page(new Page<>(pageNum, pageSize), + new LambdaQueryWrapper() + .and(StrUtil.isNotBlank(keywords), + wrapper -> + wrapper.like(Role::getName, keywords) + .or() + .like(Role::getCode, keywords) + ) + .ne(!SecurityUtils.isRoot(), Role::getCode, SystemConstants.ROOT_ROLE_CODE) // 非超级管理员不显示超级管理员角色 + .orderByAsc(Role::getSort).orderByDesc(Role::getCreateTime).orderByDesc(Role::getUpdateTime) + ); + + // 实体转换 + return roleConverter.toPageVo(rolePage); + } + + /** + * 角色下拉列表 + * + * @return {@link List