commit 4bcf22d61631fad52742691ac0f6961ff7c78fdb Author: alex_q <914269@qq.com> Date: Fri Jun 12 17:34:13 2026 +0800 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94c480e --- /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 +lib +sql + +docker/*/data/ +docker/minio/config +docker/xxljob/logs \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c0f924c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,67 @@ +# AGENTS.md - rnb Backend + +## Build & Run +- **Maven profiles**: `dev` (default), `prod` +- **Build**: `mvn clean package -DskipTests -Pdev` (or `-Pprod`) +- **Run locally**: Execute `RnBApplication.main()` (port 10800) +- **Docker**: `docker-compose -f docker/docker-compose.yml -p youlai-boot up -d` (MySQL 3306, Redis 6379, MinIO 9000/9001, XXL-Job 8080) + +## Key Configuration +- **Active profile**: Set via `spring.profiles.active` (default: `prod` in application.yml) +- **Dev DB**: `localhost:3306/rnb` (user: root, pass: 168soft.xyz) +- **Dev Redis**: `localhost:16378` (pass: CZwy16378, db: 2) +- **Prod DB**: `localhost:3308/rnb` +- **Prod Redis**: `localhost:6379` (no password) +- **JWT secret**: Must be ≥32 chars (see `security.session.jwt.secret-key` in profile yml) + +## Project Structure +``` +src/main/java/com/rnb/ +├── common/ # Shared: annotation, base, constant, enums, exception, model, result, util +├── config/ # Auto-configuration, property binding +├── core/ # Core: aspect (log, repeat-submit), filter (request-log, rate-limit), handler (data-permission, data-fill), security +├── modules/ # Business modules: member, order, product +├── shared/ # Shared modules: auth, file, codegen, mail, sms, websocket +└── system/ # System module: controller, converter, model (bo/dto/entity/form/query/vo), mapper, service +``` + +## Code Generation +- **MyBatis-Plus Generator**: Run `SystemCodeGenerator.main()` (prompts for table names, uses `sys_` prefix) +- **Templates**: `src/test/resources/templates/*.vm` (backend), `codegen.yml` (frontend) +- **Output**: `src/main/java/com/rnb/system/...`, `src/main/resources/mapper/...` + +## Testing +- **Tests skipped by default**: `maven-surefire-plugin` has `true` +- **Run single test**: `mvn test -Dtest=ClassName -DfailIfNoTests=false` + +## Common Commands +```bash +# Build dev +mvn clean package -DskipTests -Pdev + +# Build prod +mvn clean package -DskipTests -Pprod + +# Run with dev profile (from IDE or) +mvn spring-boot:run -Pdev + +# Start infra only +cd docker && docker-compose -f docker-compose.yml -p youlai-boot up -d + +# Stop infra +cd docker && docker-compose -f docker-compose.yml -p youlai-boot down +``` + +## Notable Conventions +- **Table prefix**: `rb_` (MyBatis-Plus config) +- **Logic delete**: field `isDeleted` (1=deleted, 0=active) +- **Security modes**: JWT (stateless) or Redis-token (stateful, multi-login control) +- **API docs**: Knife4j at `/doc.html` (dev), `/swagger-ui.html` (prod) +- **Port**: 10800 (both dev/prod) +- **Lombok + MapStruct**: Use `lombok-mapstruct-binding` for compatibility + +## Gotchas +- Dockerfile expects `target/youlai-boot.jar` but Maven builds `target/rnb--dev.jar` → rename or adjust Dockerfile +- DB connection validated on startup (app exits if fails) +- `application.yml` imports `codegen.yml` for code generator config +- Tests require DB/Redis running (use docker-compose) \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cf00826 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# 基础镜像 +FROM openjdk:17-jdk-alpine + +# 维护者信息 +MAINTAINER youlai + +# 设置国内镜像源(中国科技大学镜像源),修改容器时区(alpine镜像需安装tzdata来设置时区),安装字体库(验证码) +RUN echo -e https://mirrors.ustc.edu.cn/alpine/v3.7/main/ > /etc/apk/repositories \ + && apk --no-cache add tzdata && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo "Asia/Shanghai" > /etc/timezone \ + && apk --no-cache add ttf-dejavu fontconfig + +# 在运行时自动挂载 /tmp 目录为匿名卷,提高可移植性。如果 /tmp 目录没有挂载为卷,这些文件会写入容器的可写层,可能导致容器镜像膨胀。 +VOLUME /tmp + +# 将构建的 Spring Boot 可执行 JAR 复制到容器中,重命名为 app.jar +ADD target/youlai-boot.jar app.jar + +# 指定容器启动时执行的命令 +CMD java \ + -Xms512m -Xmx512m \ + -Djava.security.egd=file:/dev/./urandom \ + -jar /app.jar + +# 暴露容器的端口 +EXPOSE 8989 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a8c9ca1 --- /dev/null +++ b/README.md @@ -0,0 +1,167 @@ + +
+ logo +

youlai-boot

+ 有来技术 + 有来技术 + + 有来技术 + + + 有来技术 + +
+ 有来技术 + + 有来技术 + +
+ +![](https://raw.gitmirror.com/youlaitech/image/main/docs/rainbow.png) + +
+ 🖥️ 在线预览 | 📑 阅读文档 | 🌐 官网 +
+ +## 📢 项目简介 + +基于 JDK 17、Spring Boot 3、Spring Security 6、JWT、Redis、Mybatis-Plus、Vue 3、Element-Plus 构建的前后端分离单体权限管理系统。 [Mybatis-Flex 版本](https://gitee.com/youlaiorg/youlai-boot-flex) + +- **🚀 开发框架**: 使用 Spring Boot 3 和 Vue 3,以及 Element-Plus 等主流技术栈,实时更新。 + +- **🔐 安全认证**: 基于 Spring Security 6 原生架构,集成 JWT 令牌自动续期(无状态)和 Redis 会话多端互斥管理(实时强制离线)双重认证机制,构建企业级身份安全中枢。 + +- **🔑 权限管理**: 基于 RBAC 模型,实现细粒度的权限控制,涵盖接口方法和按钮级别。 + +- **🛠️ 功能模块**: 包括用户管理、角色管理、菜单管理、部门管理、字典管理等功能。 + +## 🌈 项目源码 + +| 项目类型 | Gitee | Github | GitCode | +| --------------| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | +| ✅ Java 后端 | [youlai-boot](https://gitee.com/youlaiorg/youlai-boot) | [youlai-boot](https://github.com/haoxianrui/youlai-boot) | [youlai-boot](https://gitcode.com/youlai/youlai-boot) | +| vue3 前端 | [vue3-element-admin](https://gitee.com/youlaiorg/vue3-element-admin) | [vue3-element-admin](https://github.com/youlaitech/vue3-element-admin) | [vue3-element-admin](https://gitcode.com/youlai/vue3-element-admin) | +| uni-app 移动端 | [vue-uniapp-template](https://gitee.com/youlaiorg/vue-uniapp-template) | [vue-uniapp-template](https://github.com/youlaitech/vue-uniapp-template) | [vue-uniapp-template](https://gitcode.com/youlai/vue-uniapp-template) | + + + +## 📚 项目文档 + +| 文档名称 | 访问地址 | +|---------------|-------------------------------------------------------------------------------------------| +| 在线接口文档 | [https://www.apifox.cn/apidoc](https://www.apifox.cn/apidoc/shared-195e783f-4d85-4235-a038-eec696de4ea5) | +| 项目介绍与使用指南 | [https://www.youlai.tech/youlai-boot/](https://www.youlai.tech/youlai-boot/) | +| 功能详解与操作手册 | [https://youlai.blog.csdn.net/article/details/145178880](https://youlai.blog.csdn.net/article/details/145178880) | +| 新手入门指南(项目0到1) | [https://youlai.blog.csdn.net/article/details/145177011](https://youlai.blog.csdn.net/article/details/145177011) | + + +## 📁 项目目录 + + +
+ 目录结构 + +
+ +``` +youlai-boot +├── docker # Docker 目录 +│ ├── docker-compose.yml # docker-compose 脚本 +├── sql # SQL脚本 +│ ├── mysql # MySQL 脚本 +├── src # 源码目录 +│ ├── common # 公共模块 +│ │ ├── annotation # 注解定义 +│ │ ├── base # 基础类 +│ │ ├── constant # 常量 +│ │ ├── enums # 枚举类型 +│ │ ├── exception # 异常处理 +│ │ ├── model # 数据模型 +│ │ ├── result # 结果封装 +│ │ └── util # 工具类 +│ ├── config # 自动装配配置 +│ │ └── property # 配置属性目录 +│ ├── core # 核心功能 +│ │ ├── aspect # 切面(日志、防重提交) +│ │ ├── filter # 过滤器(请求日志、限流) +│ │ ├── handler # 处理器(数据权限、数据填充) +│ │ └── security # Spring Security 安全模块 +│ ├── modules # 业务模块 +│ │ ├── member # 会员模块【业务模块演示】 +│ │ ├── order # 订单模块【业务模块演示】 +│ │ ├── product # 商品模块【业务模块演示】 +│ ├── shared # 共享模块 +│ │ ├── auth # 认证模块 +│ │ ├── file # 文件模块 +│ │ ├── codegen # 代码生成模块 +│ │ ├── mail # 邮件模块 +│ │ ├── sms # 短信模块 +│ │ └── websocket # WebSocket 模块 +│ ├── system # 系统模块 +│ │ ├── controller # 控制层 +│ │ ├── converter # MapStruct 转换器 +│ │ ├── event # 事件处理 +│ │ ├── handler # 处理器 +│ │ ├── listener # 监听器 +│ │ ├── model # 模型层 +│ │ │ ├── bo # 业务对象 +│ │ │ ├── dto # 数据传输对象 +│ │ │ ├── entity # 实体对象 +│ │ │ ├── form # 表单对象 +│ │ │ ├── query # 查询参数对象 +│ │ │ └── vo # 视图对象 +│ │ ├── mapper # 数据库访问层 +│ │ └── service # 业务逻辑层 +│ └── YouLaiBootApplication # 启动类 +└── end +``` +
+ + + +## 🚀 项目启动 + +📚 完整流程参考: [项目启动](https://www.youlai.tech/youlai-boot/1.%E9%A1%B9%E7%9B%AE%E5%90%AF%E5%8A%A8/) + +1. **克隆项目** + + ```bash + git clone https://gitee.com/youlaiorg/youlai-boot.git + ``` + +2. **数据库初始化** + + 执行 [youlai_boot.sql](sql/mysql/youlai_boot.sql) 脚本完成数据库创建、表结构和基础数据的初始化。 + +3. **修改配置** + + 默认连接`有来`线上 MySQL/Redis(仅读权限),本地开发时请修改 [application-dev.yml](src/main/resources/application-dev.yml) 中的 MySQL 和 Redis 连接信息。 + +4. **启动项目** + + 执行 [YoulaiBootApplication.java](src/main/java/com/rnb/YoulaiBootApplication.java) 的 main 方法完成后端项目启动; + + 访问接口文档地址 [http://localhost:8989/doc.html](http://localhost:8989/doc.html) 验证项目启动是否成功。 + + +## 🚀 项目部署 + +参考官方文档: [项目部署指南](https://www.youlai.tech/youlai-boot/5.%E9%A1%B9%E7%9B%AE%E9%83%A8%E7%BD%B2/) + + +## ✅ 项目统计 + +![](https://repobeats.axiom.co/api/embed/544c5c0b5b3611a6c4d5ef0faa243a9066b89659.svg "Repobeats analytics image") + +Thanks to all the contributors! + +[![](https://contrib.rocks/image?repo=haoxianrui/youlai-boot)](https://github.com/haoxianrui/youlai-boot/graphs/contributors) + + +## 💖 加交流群 + +① 关注「有来技术」公众号,点击菜单 **交流群** 获取加群二维码(此举防止广告进群,感谢理解和支持)。 + +② 直接添加微信 **`haoxianrui`** 备注「前端/后端/全栈」。 + +![有来技术公众号](https://foruda.gitee.com/images/1737108820762592766/3390ed0d_716974.png) \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..f27b011 --- /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:latest + 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 \ No newline at end of file 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..36aaf08 --- /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-module-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 shared 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 exception 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 shared +# 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 +# shared 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 +# module 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..2ba6c48 --- /dev/null +++ b/pom.xml @@ -0,0 +1,340 @@ + + + 4.0.0 + + com.rnb + rnb + 1.12 + 基于 Java 17 + SpringBoot 3 + Spring Security 构建的后台管理系统。 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.6 + + + + + 17 + 17 + + 5.8.40 + + 9.3.0 + 1.2.24 + 3.5.5 + + 4.5.0 + + 1.6.3 + 0.2.0 + + 3.3.0 + + 1.1.0 + + + 8.6.0 + + + 3.16.3 + + + 3.40.2 + + + 3.5.6 + 2.3 + + + 2.7.0 + + + 4.6.4 + 2.2.1 + + + 4.5.5.B + 2.9.3 + + + + + org.projectlombok + lombok + + provided + + + + cn.hutool + hutool-all + ${hutool.version} + + + + + org.projectlombok + lombok-mapstruct-binding + ${lombok-mapstruct-binding.version} + provided + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-tomcat + + + + + + org.springframework.boot + spring-boot-starter-undertow + + + + org.apache.httpcomponents.client5 + httpclient5 + 5.1 + + + + com.alibaba + fastjson + 2.0.32 + + + + 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-aop + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-websocket + + + + 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-boot3-starter + ${mybatis-plus.version} + + + + + com.github.xiaoymin + knife4j-openapi3-jakarta-spring-boot-starter + ${knife4j.version} + + + + + 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.binarywang + weixin-java-miniapp + ${weixin-java.version} + + + + + com.github.ben-manes.caffeine + caffeine + ${caffeine.version} + + + junit + junit + test + + + + + + + dev + + + dev + + + + + + + ${project.artifactId}-${project.version}-dev + + + + prod + + prod + + + ${project.artifactId}-${project.version} + + + + + + ${project.artifactId} + + + org.apache.maven.plugins + maven-surefire-plugin + + true + + + + org.springframework.boot + spring-boot-maven-plugin + + + ZIP + + + nothing + nothing + + + + + org.projectlombok + lombok + + + + + + + + \ No newline at end of file diff --git a/src/main/java/com/rnb/RnBApplication.java b/src/main/java/com/rnb/RnBApplication.java new file mode 100644 index 0000000..9a9e5ee --- /dev/null +++ b/src/main/java/com/rnb/RnBApplication.java @@ -0,0 +1,34 @@ +package com.rnb; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.scheduling.annotation.EnableScheduling; + +import javax.sql.DataSource; + +/** + * 应用启动类 + * + * @author Alex.Qu + * @since 0.0.1 + */ +@SpringBootApplication +@ConfigurationPropertiesScan // 开启配置属性绑定 +@EnableScheduling +public class RnBApplication { + public static void main(String[] args) { + ConfigurableApplicationContext ac = SpringApplication.run(RnBApplication.class, args); + try { + // ===== 在项目初始化bean后检验数据库连接是否 + DataSource dataSource = (DataSource) ac.getBean("dataSource"); + dataSource.getConnection().close(); + } catch (Exception e) { +// e.printStackTrace(); + System.out.println(e.getMessage()); + // ===== 当检测数据库连接失败时, 停止项目启动 + System.exit(-1); + } + } +} diff --git a/src/main/java/com/rnb/common/annotation/DataPermission.java b/src/main/java/com/rnb/common/annotation/DataPermission.java new file mode 100644 index 0000000..ec20ea3 --- /dev/null +++ b/src/main/java/com/rnb/common/annotation/DataPermission.java @@ -0,0 +1,28 @@ +package com.rnb.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/rnb/common/annotation/Log.java b/src/main/java/com/rnb/common/annotation/Log.java new file mode 100644 index 0000000..816ac2b --- /dev/null +++ b/src/main/java/com/rnb/common/annotation/Log.java @@ -0,0 +1,49 @@ +package com.rnb.common.annotation; + +import com.rnb.common.enums.LogModuleEnum; + +import java.lang.annotation.*; + +/** + * 日志注解 + * + * @author Ray + * @since 2024/6/25 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +@Documented +public @interface Log { + + /** + * 日志描述 + * + * @return 日志描述 + */ + String value() default ""; + + /** + * 日志模块 + * + * @return 日志模块 + */ + + LogModuleEnum module(); + + /** + * 是否记录请求参数 + * + * @return 是否记录请求参数 + */ + boolean params() default true; + + /** + * 是否记录响应结果 + *
+ * 响应结果默认不记录,避免日志过大 + * @return 是否记录响应结果 + */ + boolean result() default false; + + +} \ No newline at end of file diff --git a/src/main/java/com/rnb/common/annotation/RepeatSubmit.java b/src/main/java/com/rnb/common/annotation/RepeatSubmit.java new file mode 100644 index 0000000..93ee9d6 --- /dev/null +++ b/src/main/java/com/rnb/common/annotation/RepeatSubmit.java @@ -0,0 +1,27 @@ +package com.rnb.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/rnb/common/annotation/ValidField.java b/src/main/java/com/rnb/common/annotation/ValidField.java new file mode 100644 index 0000000..2bdc89e --- /dev/null +++ b/src/main/java/com/rnb/common/annotation/ValidField.java @@ -0,0 +1,35 @@ +package com.rnb.common.annotation; + +import com.rnb.core.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/rnb/common/base/BaseEntity.java b/src/main/java/com/rnb/common/base/BaseEntity.java new file mode 100644 index 0000000..68d4802 --- /dev/null +++ b/src/main/java/com/rnb/common/base/BaseEntity.java @@ -0,0 +1,48 @@ +package com.rnb.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/rnb/common/base/BasePageQuery.java b/src/main/java/com/rnb/common/base/BasePageQuery.java new file mode 100644 index 0000000..c484a81 --- /dev/null +++ b/src/main/java/com/rnb/common/base/BasePageQuery.java @@ -0,0 +1,29 @@ +package com.rnb.common.base; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 基础分页请求对象 + * + * @author haoxr + * @since 2021/2/28 + */ +@Data +@Schema +public class BasePageQuery implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "页码", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private int pageNum = 1; + + @Schema(description = "每页记录数", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + private int pageSize = 10; + + +} diff --git a/src/main/java/com/rnb/common/base/BaseVO.java b/src/main/java/com/rnb/common/base/BaseVO.java new file mode 100644 index 0000000..797df8b --- /dev/null +++ b/src/main/java/com/rnb/common/base/BaseVO.java @@ -0,0 +1,21 @@ +package com.rnb.common.base; + +import lombok.Data; +import lombok.ToString; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 视图对象基类 + * + * @author haoxr + * @since 2022/10/22 + */ +@Data +@ToString +public class BaseVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; +} diff --git a/src/main/java/com/rnb/common/base/IBaseEnum.java b/src/main/java/com/rnb/common/base/IBaseEnum.java new file mode 100644 index 0000000..28ef055 --- /dev/null +++ b/src/main/java/com/rnb/common/base/IBaseEnum.java @@ -0,0 +1,88 @@ +package com.rnb.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/rnb/common/constant/JwtClaimConstants.java b/src/main/java/com/rnb/common/constant/JwtClaimConstants.java new file mode 100644 index 0000000..8e2fb4d --- /dev/null +++ b/src/main/java/com/rnb/common/constant/JwtClaimConstants.java @@ -0,0 +1,33 @@ +package com.rnb.common.constant; + +/** + * JWT Claims声明常量 + *

+ * JWT Claims 属于 Payload 的一部分,包含了一些实体(通常指的用户)的状态和额外的元数据。 + * + * @author haoxr + * @since 2023/11/24 + */ +public interface JwtClaimConstants { + + /** + * 用户ID + */ + String USER_ID = "userId"; + + /** + * 部门ID + */ + String DEPT_ID = "deptId"; + + /** + * 数据权限 + */ + String DATA_SCOPE = "dataScope"; + + /** + * 权限(角色Code)集合 + */ + String AUTHORITIES = "authorities"; + +} diff --git a/src/main/java/com/rnb/common/constant/RedisConstants.java b/src/main/java/com/rnb/common/constant/RedisConstants.java new file mode 100644 index 0000000..a338751 --- /dev/null +++ b/src/main/java/com/rnb/common/constant/RedisConstants.java @@ -0,0 +1,59 @@ +package com.rnb.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 -> OnlineUser) + String ACCESS_TOKEN_USER = "auth:token:access:{}"; + // 存储刷新令牌对应的用户信息(refreshToken -> OnlineUser) + 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(用于退出登录或注销) + String BLACKLIST_TOKEN = "auth:token:blacklist:{}"; + } + + /** + * 验证码模块 + */ + 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/rnb/common/constant/SecurityConstants.java b/src/main/java/com/rnb/common/constant/SecurityConstants.java new file mode 100644 index 0000000..31a2f07 --- /dev/null +++ b/src/main/java/com/rnb/common/constant/SecurityConstants.java @@ -0,0 +1,25 @@ +package com.rnb.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/rnb/common/constant/SystemConstants.java b/src/main/java/com/rnb/common/constant/SystemConstants.java new file mode 100644 index 0000000..916d6d5 --- /dev/null +++ b/src/main/java/com/rnb/common/constant/SystemConstants.java @@ -0,0 +1,33 @@ +package com.rnb.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"; + String SYSTEM_CONFIG_CLIENT_RESP_KEY = "CLIENT_RESP_KEY"; + int DEFAULT_CLIENT_KEY = 5; +} diff --git a/src/main/java/com/rnb/common/enums/DataScopeEnum.java b/src/main/java/com/rnb/common/enums/DataScopeEnum.java new file mode 100644 index 0000000..9abfde0 --- /dev/null +++ b/src/main/java/com/rnb/common/enums/DataScopeEnum.java @@ -0,0 +1,31 @@ +package com.rnb.common.enums; + +import com.rnb.common.base.IBaseEnum; +import lombok.Getter; + +/** + * 数据权限枚举 + * + * @author Ray.Hao + * @since 2.3.0 + */ +@Getter +public enum DataScopeEnum implements IBaseEnum { + + /** + * value 越小,数据权限范围越大 + */ + ALL(1, "所有数据"), + DEPT_AND_SUB(2, "部门及子部门数据"), + DEPT(3, "本部门数据"), + SELF(4, "本人数据"); + + private final Integer value; + + private final String label; + + DataScopeEnum(Integer value, String label) { + this.value = value; + this.label = label; + } +} diff --git a/src/main/java/com/rnb/common/enums/EnvEnum.java b/src/main/java/com/rnb/common/enums/EnvEnum.java new file mode 100644 index 0000000..fa780c2 --- /dev/null +++ b/src/main/java/com/rnb/common/enums/EnvEnum.java @@ -0,0 +1,26 @@ +package com.rnb.common.enums; + +import com.rnb.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/rnb/common/enums/LogModuleEnum.java b/src/main/java/com/rnb/common/enums/LogModuleEnum.java new file mode 100644 index 0000000..50e493f --- /dev/null +++ b/src/main/java/com/rnb/common/enums/LogModuleEnum.java @@ -0,0 +1,33 @@ +package com.rnb.common.enums; + +import com.fasterxml.jackson.annotation.JsonValue; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +/** + * 日志模块枚举 + * + * @author Ray + * @since 2.10.0 + */ +@Schema(enumAsRef = true) +@Getter +public enum LogModuleEnum { + + EXCEPTION("异常"), + LOGIN("登录"), + USER("用户"), + DEPT("部门"), + ROLE("角色"), + MENU("菜单"), + DICT("字典"), + SETTING("系统配置"), + OTHER("其他"); + + @JsonValue + private final String moduleName; + + LogModuleEnum(String moduleName) { + this.moduleName = moduleName; + } +} \ No newline at end of file diff --git a/src/main/java/com/rnb/common/enums/RequestMethodEnum.java b/src/main/java/com/rnb/common/enums/RequestMethodEnum.java new file mode 100644 index 0000000..9f5ba89 --- /dev/null +++ b/src/main/java/com/rnb/common/enums/RequestMethodEnum.java @@ -0,0 +1,52 @@ +package com.rnb.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum RequestMethodEnum { + /** + * 搜寻 @AnonymousGetMapping + */ + GET("GET"), + + /** + * 搜寻 @AnonymousPostMapping + */ + POST("POST"), + + /** + * 搜寻 @AnonymousPutMapping + */ + PUT("PUT"), + + /** + * 搜寻 @AnonymousPatchMapping + */ + PATCH("PATCH"), + + /** + * 搜寻 @AnonymousDeleteMapping + */ + DELETE("DELETE"), + + /** + * 否则就是所有 Request 接口都放行 + */ + ALL("All"); + + /** + * Request 类型 + */ + private final String type; + + public static RequestMethodEnum find(String type) { + for (RequestMethodEnum value : RequestMethodEnum.values()) { + if (value.getType().equals(type)) { + return value; + } + } + return ALL; + } +} diff --git a/src/main/java/com/rnb/common/enums/StatusEnum.java b/src/main/java/com/rnb/common/enums/StatusEnum.java new file mode 100644 index 0000000..24968c6 --- /dev/null +++ b/src/main/java/com/rnb/common/enums/StatusEnum.java @@ -0,0 +1,27 @@ +package com.rnb.common.enums; + +import com.rnb.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/rnb/common/exception/BusinessException.java b/src/main/java/com/rnb/common/exception/BusinessException.java new file mode 100644 index 0000000..7f908a4 --- /dev/null +++ b/src/main/java/com/rnb/common/exception/BusinessException.java @@ -0,0 +1,45 @@ +package com.rnb.common.exception; + +import com.rnb.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/rnb/common/exception/GlobalExceptionHandler.java b/src/main/java/com/rnb/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..f5c0a55 --- /dev/null +++ b/src/main/java/com/rnb/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,265 @@ +package com.rnb.common.exception; + +import cn.hutool.core.util.StrUtil; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.rnb.common.result.Result; +import com.rnb.common.result.ResultCode; +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 jakarta.servlet.ServletException; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; + +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.BAD_REQUEST) + 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.BAD_REQUEST) + 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.BAD_REQUEST) + 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.BAD_REQUEST) + 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.BAD_REQUEST) + 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.BAD_REQUEST) + public Result processException(ServletException e) { + log.error(e.getMessage(), e); + return Result.failed(e.getMessage()); + } + + /** + * 处理非法参数异常 + *

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

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

+ * 当请求体不可读时,会抛出 HttpMessageNotReadableException 异常。 + */ + @ExceptionHandler(HttpMessageNotReadableException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + 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.BAD_REQUEST) + public Result processException(TypeMismatchException e) { + log.error(e.getMessage(), e); + return Result.failed(e.getMessage()); + } + + /** + * 处理 SQL 语法错误异常 + *

+ * 当 SQL 语法错误时,会抛出 BadSqlGrammarException 异常。 + */ + @ExceptionHandler(BadSqlGrammarException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + 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.ACCESS_UNAUTHORIZED); + } else { + return Result.failed(e.getMessage()); + } + } + + /** + * 处理 SQL 语法错误异常 + *

+ * 当 SQL 语法错误时,会抛出 SQLSyntaxErrorException 异常。 + */ + @ExceptionHandler(SQLSyntaxErrorException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public Result processSQLSyntaxErrorException(SQLSyntaxErrorException e) { + log.error(e.getMessage(), e); + return Result.failed(e.getMessage()); + } + + /** + * 处理业务异常 + *

+ * 当业务逻辑发生错误时,会抛出 BusinessException 异常。 + */ + @ExceptionHandler(BusinessException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + 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.BAD_REQUEST) + 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; + } +} \ No newline at end of file diff --git a/src/main/java/com/rnb/common/model/KeyValue.java b/src/main/java/com/rnb/common/model/KeyValue.java new file mode 100644 index 0000000..7c98af8 --- /dev/null +++ b/src/main/java/com/rnb/common/model/KeyValue.java @@ -0,0 +1,30 @@ +package com.rnb.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; + +} \ No newline at end of file diff --git a/src/main/java/com/rnb/common/model/Option.java b/src/main/java/com/rnb/common/model/Option.java new file mode 100644 index 0000000..ff85f10 --- /dev/null +++ b/src/main/java/com/rnb/common/model/Option.java @@ -0,0 +1,53 @@ +package com.rnb.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; + +} \ No newline at end of file diff --git a/src/main/java/com/rnb/common/result/ExcelResult.java b/src/main/java/com/rnb/common/result/ExcelResult.java new file mode 100644 index 0000000..c45edf2 --- /dev/null +++ b/src/main/java/com/rnb/common/result/ExcelResult.java @@ -0,0 +1,43 @@ +package com.rnb.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/rnb/common/result/IResultCode.java b/src/main/java/com/rnb/common/result/IResultCode.java new file mode 100644 index 0000000..144a220 --- /dev/null +++ b/src/main/java/com/rnb/common/result/IResultCode.java @@ -0,0 +1,15 @@ +package com.rnb.common.result; + +/** + * 响应码接口 + * + * @author Ray.Hao + * @since 1.0.0 + **/ +public interface IResultCode { + + String getCode(); + + String getMsg(); + +} diff --git a/src/main/java/com/rnb/common/result/PageResult.java b/src/main/java/com/rnb/common/result/PageResult.java new file mode 100644 index 0000000..f3785f2 --- /dev/null +++ b/src/main/java/com/rnb/common/result/PageResult.java @@ -0,0 +1,46 @@ +package com.rnb.common.result; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +/** + * 分页响应结构体 + * + * @author Ray + * @since 2022/2/18 + */ +@Data +public class PageResult implements Serializable { + + private String code; + + private Data data; + + private String msg; + + public static PageResult success(IPage page) { + PageResult result = new PageResult<>(); + result.setCode(ResultCode.SUCCESS.getCode()); + + Data data = new Data<>(); + data.setList(page.getRecords()); + data.setTotal(page.getTotal()); + + result.setData(data); + result.setMsg(ResultCode.SUCCESS.getMsg()); + return result; + } + + @lombok.Data + public static class Data { + + private List list; + + private long total; + + } + +} diff --git a/src/main/java/com/rnb/common/result/Result.java b/src/main/java/com/rnb/common/result/Result.java new file mode 100644 index 0000000..593e9f1 --- /dev/null +++ b/src/main/java/com/rnb/common/result/Result.java @@ -0,0 +1,74 @@ +package com.rnb.common.result; + +import cn.hutool.core.util.StrUtil; +import lombok.Data; + +import java.io.Serializable; + +/** + * 统一响应结构体 + * + * @author Ray + * @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); + } + + 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; + } + + public static boolean isSuccess(Result result) { + return result != null && ResultCode.SUCCESS.getCode().equals(result.getCode()); + } +} diff --git a/src/main/java/com/rnb/common/result/ResultCode.java b/src/main/java/com/rnb/common/result/ResultCode.java new file mode 100644 index 0000000..11b63a3 --- /dev/null +++ b/src/main/java/com/rnb/common/result/ResultCode.java @@ -0,0 +1,296 @@ +package com.rnb.common.result; + +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * 响应码枚举 + *

+ * 参考阿里巴巴开发手册响应码规范 + * 00000 正常 + * A**** 用户端错误 + * B**** 系统执行出错 + * C**** 调用第三方服务出错 + * + * @author Ray.Hao + * @since 2020/6/23 + **/ +@AllArgsConstructor +@NoArgsConstructor +public enum ResultCode implements IResultCode, Serializable { + + SUCCESS("00000", "一切ok"), + + /** 一级宏观错误码 */ + USER_ERROR("A0001", "用户端错误"), + + /** 二级宏观错误码 */ + USER_REGISTRATION_ERROR("A0100", "用户注册错误"), + USER_NOT_AGREE_PRIVACY_AGREEMENT("A0101", "用户未同意隐私协议"), + REGISTRATION_COUNTRY_OR_REGION_RESTRICTED("A0102", "注册国家或地区受限"), + + USERNAME_VERIFICATION_FAILED("A0110", "用户名校验失败"), + USERNAME_ALREADY_EXISTS("A0111", "用户名已存在"), + USERNAME_CONTAINS_SENSITIVE_WORDS("A0112", "用户名包含敏感词"), + USERNAME_CONTAINS_SPECIAL_CHARACTERS("A0113", "用户名包含特殊字符"), + + PASSWORD_VERIFICATION_FAILED("A0120", "密码校验失败"), + PASSWORD_LENGTH_NOT_ENOUGH("A0121", "密码长度不够"), + PASSWORD_STRENGTH_NOT_ENOUGH("A0122", "密码强度不够"), + + VERIFICATION_CODE_INPUT_ERROR("A0130", "校验码输入错误"), + SMS_VERIFICATION_CODE_INPUT_ERROR("A0131", "短信校验码输入错误"), + EMAIL_VERIFICATION_CODE_INPUT_ERROR("A0132", "邮件校验码输入错误"), + VOICE_VERIFICATION_CODE_INPUT_ERROR("A0133", "语音校验码输入错误"), + + USER_CERTIFICATE_EXCEPTION("A0140", "用户证件异常"), + USER_CERTIFICATE_TYPE_NOT_SELECTED("A0141", "用户证件类型未选择"), + MAINLAND_ID_NUMBER_VERIFICATION_ILLEGAL("A0142", "大陆身份证编号校验非法"), + + USER_BASIC_INFORMATION_VERIFICATION_FAILED("A0150", "用户基本信息校验失败"), + PHONE_FORMAT_VERIFICATION_FAILED("A0151", "手机格式校验失败"), + ADDRESS_FORMAT_VERIFICATION_FAILED("A0152", "地址格式校验失败"), + EMAIL_FORMAT_VERIFICATION_FAILED("A0153", "邮箱格式校验失败"), + + /** 二级宏观错误码 */ + USER_LOGIN_EXCEPTION("A0200", "用户登录异常"), + USER_ACCOUNT_FROZEN("A0201", "用户账户被冻结"), + USER_ACCOUNT_ABOLISHED("A0202", "用户账户已作废"), + + USER_PASSWORD_ERROR("A0210", "用户名或密码错误"), + USER_INPUT_PASSWORD_ERROR_LIMIT_EXCEEDED("A0211", "用户输入密码错误次数超限"), + USER_NOT_EXIST("A0212", "用户不存在"), + + USER_IDENTITY_VERIFICATION_FAILED("A0220", "用户身份校验失败"), + USER_FINGERPRINT_RECOGNITION_FAILED("A0221", "用户指纹识别失败"), + USER_FACE_RECOGNITION_FAILED("A0222", "用户面容识别失败"), + USER_NOT_AUTHORIZED_THIRD_PARTY_LOGIN("A0223", "用户未获得第三方登录授权"), + + ACCESS_TOKEN_INVALID("A0230", "访问令牌无效或已过期"), + REFRESH_TOKEN_INVALID("A0231", "刷新令牌无效或已过期"), + + // 验证码错误 + USER_VERIFICATION_CODE_ERROR("A0240", "验证码错误"), + USER_VERIFICATION_CODE_ATTEMPT_LIMIT_EXCEEDED("A0241", "用户验证码尝试次数超限"), + USER_VERIFICATION_CODE_EXPIRED("A0242", "用户验证码过期"), + + /** 二级宏观错误码 */ + ACCESS_PERMISSION_EXCEPTION("A0300", "访问权限异常"), + ACCESS_UNAUTHORIZED("A0301", "访问未授权"), + AUTHORIZATION_IN_PROGRESS("A0302", "正在授权中"), + USER_AUTHORIZATION_APPLICATION_REJECTED("A0303", "用户授权申请被拒绝"), + + ACCESS_OBJECT_PRIVACY_SETTINGS_BLOCKED("A0310", "因访问对象隐私设置被拦截"), + AUTHORIZATION_EXPIRED("A0311", "授权已过期"), + NO_PERMISSION_TO_USE_API("A0312", "无权限使用 API"), + + USER_ACCESS_BLOCKED("A0320", "用户访问被拦截"), + BLACKLISTED_USER("A0321", "黑名单用户"), + ACCOUNT_FROZEN("A0322", "账号被冻结"), + ILLEGAL_IP_ADDRESS("A0323", "非法 IP 地址"), + GATEWAY_ACCESS_RESTRICTED("A0324", "网关访问受限"), + REGION_BLACKLIST("A0325", "地域黑名单"), + + SERVICE_ARREARS("A0330", "服务已欠费"), + + USER_SIGNATURE_EXCEPTION("A0340", "用户签名异常"), + RSA_SIGNATURE_ERROR("A0341", "RSA 签名错误"), + + /** 二级宏观错误码 */ + USER_REQUEST_PARAMETER_ERROR("A0400", "用户请求参数错误"), + CONTAINS_ILLEGAL_MALICIOUS_REDIRECT_LINK("A0401", "包含非法恶意跳转链接"), + INVALID_USER_INPUT("A0402", "无效的用户输入"), + + REQUEST_REQUIRED_PARAMETER_IS_EMPTY("A0410", "请求必填参数为空"), + + REQUEST_PARAMETER_VALUE_EXCEEDS_ALLOWED_RANGE("A0420", "请求参数值超出允许的范围"), + PARAMETER_FORMAT_MISMATCH("A0421", "参数格式不匹配"), + + USER_INPUT_CONTENT_ILLEGAL("A0430", "用户输入内容非法"), + CONTAINS_PROHIBITED_SENSITIVE_WORDS("A0431", "包含违禁敏感词"), + + USER_OPERATION_EXCEPTION("A0440", "用户操作异常"), + + /** 二级宏观错误码 */ + USER_REQUEST_SERVICE_EXCEPTION("A0500", "用户请求服务异常"), + REQUEST_LIMIT_EXCEEDED("A0501", "请求次数超出限制"), + REQUEST_CONCURRENCY_LIMIT_EXCEEDED("A0502", "请求并发数超出限制"), + USER_OPERATION_PLEASE_WAIT("A0503", "用户操作请等待"), + WEBSOCKET_CONNECTION_EXCEPTION("A0504", "WebSocket 连接异常"), + WEBSOCKET_CONNECTION_DISCONNECTED("A0505", "WebSocket 连接断开"), + USER_DUPLICATE_REQUEST("A0506", "请求过于频繁,请稍后再试。"), + + /** 二级宏观错误码 */ + USER_RESOURCE_EXCEPTION("A0600", "用户资源异常"), + ACCOUNT_BALANCE_INSUFFICIENT("A0601", "账户余额不足"), + USER_DISK_SPACE_INSUFFICIENT("A0602", "用户磁盘空间不足"), + USER_MEMORY_SPACE_INSUFFICIENT("A0603", "用户内存空间不足"), + USER_OSS_CAPACITY_INSUFFICIENT("A0604", "用户 OSS 容量不足"), + USER_QUOTA_EXHAUSTED("A0605", "用户配额已用光"), + USER_RESOURCE_NOT_FOUND("A0606", "用户资源不存在"), + + /** 二级宏观错误码 */ + UPLOAD_FILE_EXCEPTION("A0700", "上传文件异常"), + UPLOAD_FILE_TYPE_MISMATCH("A0701", "上传文件类型不匹配"), + UPLOAD_FILE_TOO_LARGE("A0702", "上传文件太大"), + UPLOAD_IMAGE_TOO_LARGE("A0703", "上传图片太大"), + UPLOAD_VIDEO_TOO_LARGE("A0704", "上传视频太大"), + UPLOAD_COMPRESSED_FILE_TOO_LARGE("A0705", "上传压缩文件太大"), + + DELETE_FILE_EXCEPTION("A0710", "删除文件异常"), + + /** 二级宏观错误码 */ + USER_CURRENT_VERSION_EXCEPTION("A0800", "用户当前版本异常"), + USER_INSTALLED_VERSION_NOT_MATCH_SYSTEM("A0801", "用户安装版本与系统不匹配"), + USER_INSTALLED_VERSION_TOO_LOW("A0802", "用户安装版本过低"), + USER_INSTALLED_VERSION_TOO_HIGH("A0803", "用户安装版本过高"), + USER_INSTALLED_VERSION_EXPIRED("A0804", "用户安装版本已过期"), + USER_API_REQUEST_VERSION_NOT_MATCH("A0805", "用户 API 请求版本不匹配"), + USER_API_REQUEST_VERSION_TOO_HIGH("A0806", "用户 API 请求版本过高"), + USER_API_REQUEST_VERSION_TOO_LOW("A0807", "用户 API 请求版本过低"), + + /** 二级宏观错误码 */ + USER_PRIVACY_NOT_AUTHORIZED("A0900", "用户隐私未授权"), + USER_PRIVACY_NOT_SIGNED("A0901", "用户隐私未签署"), + USER_CAMERA_NOT_AUTHORIZED("A0903", "用户相机未授权"), + USER_PHOTO_LIBRARY_NOT_AUTHORIZED("A0904", "用户图片库未授权"), + USER_FILE_NOT_AUTHORIZED("A0905", "用户文件未授权"), + USER_LOCATION_INFORMATION_NOT_AUTHORIZED("A0906", "用户位置信息未授权"), + USER_CONTACTS_NOT_AUTHORIZED("A0907", "用户通讯录未授权"), + + /** 二级宏观错误码 */ + USER_DEVICE_EXCEPTION("A1000", "用户设备异常"), + USER_CAMERA_EXCEPTION("A1001", "用户相机异常"), + USER_MICROPHONE_EXCEPTION("A1002", "用户麦克风异常"), + USER_EARPIECE_EXCEPTION("A1003", "用户听筒异常"), + USER_SPEAKER_EXCEPTION("A1004", "用户扬声器异常"), + USER_GPS_POSITIONING_EXCEPTION("A1005", "用户 GPS 定位异常"), + + /** 一级宏观错误码 */ + SYSTEM_ERROR("B0001", "系统执行出错"), + + /** 二级宏观错误码 */ + SYSTEM_EXECUTION_TIMEOUT("B0100", "系统执行超时"), + + /** 二级宏观错误码 */ + SYSTEM_DISASTER_RECOVERY_FUNCTION_TRIGGERED("B0200", "系统容灾功能被触发"), + + SYSTEM_RATE_LIMITING("B0210", "系统限流"), + + SYSTEM_FUNCTION_DEGRADATION("B0220", "系统功能降级"), + + /** 二级宏观错误码 */ + SYSTEM_RESOURCE_EXCEPTION("B0300", "系统资源异常"), + SYSTEM_RESOURCE_EXHAUSTED("B0310", "系统资源耗尽"), + SYSTEM_DISK_SPACE_EXHAUSTED("B0311", "系统磁盘空间耗尽"), + SYSTEM_MEMORY_EXHAUSTED("B0312", "系统内存耗尽"), + FILE_HANDLE_EXHAUSTED("B0313", "文件句柄耗尽"), + SYSTEM_CONNECTION_POOL_EXHAUSTED("B0314", "系统连接池耗尽"), + SYSTEM_THREAD_POOL_EXHAUSTED("B0315", "系统线程池耗尽"), + + SYSTEM_RESOURCE_ACCESS_EXCEPTION("B0320", "系统资源访问异常"), + SYSTEM_READ_DISK_FILE_FAILED("B0321", "系统读取磁盘文件失败"), + + + /** 一级宏观错误码 */ + THIRD_PARTY_SERVICE_ERROR("C0001", "调用第三方服务出错"), + + /** 二级宏观错误码 */ + MIDDLEWARE_SERVICE_ERROR("C0100", "中间件服务出错"), + + RPC_SERVICE_ERROR("C0110", "RPC 服务出错"), + RPC_SERVICE_NOT_FOUND("C0111", "RPC 服务未找到"), + RPC_SERVICE_NOT_REGISTERED("C0112", "RPC 服务未注册"), + INTERFACE_NOT_EXIST("C0113", "接口不存在"), + + MESSAGE_SERVICE_ERROR("C0120", "消息服务出错"), + MESSAGE_DELIVERY_ERROR("C0121", "消息投递出错"), + MESSAGE_CONSUMPTION_ERROR("C0122", "消息消费出错"), + MESSAGE_SUBSCRIPTION_ERROR("C0123", "消息订阅出错"), + MESSAGE_GROUP_NOT_FOUND("C0124", "消息分组未查到"), + + CACHE_SERVICE_ERROR("C0130", "缓存服务出错"), + KEY_LENGTH_EXCEEDS_LIMIT("C0131", "key 长度超过限制"), + VALUE_LENGTH_EXCEEDS_LIMIT("C0132", "value 长度超过限制"), + STORAGE_CAPACITY_FULL("C0133", "存储容量已满"), + UNSUPPORTED_DATA_FORMAT("C0134", "不支持的数据格式"), + + CONFIGURATION_SERVICE_ERROR("C0140", "配置服务出错"), + + NETWORK_RESOURCE_SERVICE_ERROR("C0150", "网络资源服务出错"), + VPN_SERVICE_ERROR("C0151", "VPN 服务出错"), + CDN_SERVICE_ERROR("C0152", "CDN 服务出错"), + DOMAIN_NAME_RESOLUTION_SERVICE_ERROR("C0153", "域名解析服务出错"), + GATEWAY_SERVICE_ERROR("C0154", "网关服务出错"), + + /** 二级宏观错误码 */ + THIRD_PARTY_SYSTEM_EXECUTION_TIMEOUT("C0200", "第三方系统执行超时"), + + RPC_EXECUTION_TIMEOUT("C0210", "RPC 执行超时"), + + MESSAGE_DELIVERY_TIMEOUT("C0220", "消息投递超时"), + + CACHE_SERVICE_TIMEOUT("C0230", "缓存服务超时"), + + CONFIGURATION_SERVICE_TIMEOUT("C0240", "配置服务超时"), + + DATABASE_SERVICE_TIMEOUT("C0250", "数据库服务超时"), + + /** 二级宏观错误码 */ + DATABASE_SERVICE_ERROR("C0300", "数据库服务出错"), + + TABLE_NOT_EXIST("C0311", "表不存在"), + COLUMN_NOT_EXIST("C0312", "列不存在"), + + MULTIPLE_SAME_NAME_COLUMNS_IN_MULTI_TABLE_ASSOCIATION("C0321", "多表关联中存在多个相同名称的列"), + + DATABASE_DEADLOCK("C0331", "数据库死锁"), + + PRIMARY_KEY_CONFLICT("C0341", "主键冲突"), + + /** 二级宏观错误码 */ + THIRD_PARTY_DISASTER_RECOVERY_SYSTEM_TRIGGERED("C0400", "第三方容灾系统被触发"), + THIRD_PARTY_SYSTEM_RATE_LIMITING("C0401", "第三方系统限流"), + THIRD_PARTY_FUNCTION_DEGRADATION("C0402", "第三方功能降级"), + + /** 二级宏观错误码 */ + NOTIFICATION_SERVICE_ERROR("C0500", "通知服务出错"), + SMS_REMINDER_SERVICE_FAILED("C0501", "短信提醒服务失败"), + VOICE_REMINDER_SERVICE_FAILED("C0502", "语音提醒服务失败"), + EMAIL_REMINDER_SERVICE_FAILED("C0503", "邮件提醒服务失败"); + + + @Override + public String getCode() { + return code; + } + + @Override + public String getMsg() { + return msg; + } + + private String code; + + private String 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; // 默认系统执行错误 + } +} \ No newline at end of file diff --git a/src/main/java/com/rnb/common/util/DateUtils.java b/src/main/java/com/rnb/common/util/DateUtils.java new file mode 100644 index 0000000..a7d84e9 --- /dev/null +++ b/src/main/java/com/rnb/common/util/DateUtils.java @@ -0,0 +1,61 @@ + +package com.rnb.common.util; + +import cn.hutool.core.date.DateTime; +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.ReflectUtil; +import cn.hutool.core.util.StrUtil; +import org.springframework.format.annotation.DateTimeFormat; + +import java.lang.reflect.Field; + +/** + * 日期工具类 + * + * @author haoxr + * @since 2.4.2 + */ +public class DateUtils { + + /** + * 区间日期格式化为数据库日期格式 + *

+ * eg:2021-01-01 → 2021-01-01 00:00:00 + * + * @param obj 要处理的对象 + * @param startTimeFieldName 起始时间字段名 + * @param endTimeFieldName 结束时间字段名 + */ + public static void toDatabaseFormat(Object obj, String startTimeFieldName, String endTimeFieldName) { + Field startTimeField = ReflectUtil.getField(obj.getClass(), startTimeFieldName); + Field endTimeField = ReflectUtil.getField(obj.getClass(), endTimeFieldName); + + if (startTimeField != null) { + processDateTimeField(obj, startTimeField, startTimeFieldName, "yyyy-MM-dd 00:00:00"); + } + + if (endTimeField != null) { + processDateTimeField(obj, endTimeField, endTimeFieldName, "yyyy-MM-dd 23:59:59"); + } + } + + /** + * 处理日期字段 + * + * @param obj 要处理的对象 + * @param field 字段 + * @param fieldName 字段名 + * @param targetPattern 目标数据库日期格式 + */ + private static void processDateTimeField(Object obj, Field field, String fieldName, String targetPattern) { + Object fieldValue = ReflectUtil.getFieldValue(obj, fieldName); + if (fieldValue != null) { + // 得到原始的日期格式 + String pattern = field.isAnnotationPresent(DateTimeFormat.class) ? field.getAnnotation(DateTimeFormat.class).pattern() : "yyyy-MM-dd"; + // 转换为日期对象 + DateTime dateTime = DateUtil.parse(StrUtil.toString(fieldValue), pattern); + // 转换为目标数据库日期格式 + ReflectUtil.setFieldValue(obj, fieldName, dateTime.toString(targetPattern)); + } + } +} diff --git a/src/main/java/com/rnb/common/util/ExcelUtils.java b/src/main/java/com/rnb/common/util/ExcelUtils.java new file mode 100644 index 0000000..1650e98 --- /dev/null +++ b/src/main/java/com/rnb/common/util/ExcelUtils.java @@ -0,0 +1,19 @@ +package com.rnb.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/rnb/common/util/IPUtils.java b/src/main/java/com/rnb/common/util/IPUtils.java new file mode 100644 index 0000000..0ee95b1 --- /dev/null +++ b/src/main/java/com/rnb/common/util/IPUtils.java @@ -0,0 +1,139 @@ +package com.rnb.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/rnb/common/util/ResponseUtils.java b/src/main/java/com/rnb/common/util/ResponseUtils.java new file mode 100644 index 0000000..73516bb --- /dev/null +++ b/src/main/java/com/rnb/common/util/ResponseUtils.java @@ -0,0 +1,83 @@ +package com.rnb.common.util; + +import cn.hutool.json.JSONUtil; +import com.rnb.common.result.Result; +import com.rnb.common.result.ResultCode; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; + +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; + +/** + * 响应工具类 + * + * @author Ray.Hao + * @since 2.0.0 + */ +@Slf4j +public class ResponseUtils { + + + /** + * 异常消息返回(适用过滤器中处理异常响应) + * + * @param response HttpServletResponse + * @param resultCode 响应结果码 + */ + public static void writeErrMsg(HttpServletResponse response, ResultCode resultCode) { + int status = getHttpStatus(resultCode); + + response.setStatus(status); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + + try (PrintWriter writer = response.getWriter()) { + String jsonResponse = JSONUtil.toJsonStr(Result.failed(resultCode)); + writer.print(jsonResponse); + writer.flush(); // 确保将响应内容写入到输出流 + } catch (IOException e) { + log.error("响应异常处理失败", e); + } + } + + /** + * 异常消息返回(适用过滤器中处理异常响应) + * + * @param response HttpServletResponse + * @param resultCode 响应结果码 + */ + public static void writeErrMsg(HttpServletResponse response, ResultCode resultCode, String message) { + int status = getHttpStatus(resultCode); + + response.setStatus(status); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + + try (PrintWriter writer = response.getWriter()) { + String jsonResponse = JSONUtil.toJsonStr(Result.failed(resultCode, message)); + writer.print(jsonResponse); + writer.flush(); // 确保将响应内容写入到输出流 + } catch (IOException e) { + log.error("响应异常处理失败", e); + } + } + + + /** + * 根据结果码获取HTTP状态码 + * + * @param resultCode 结果码 + * @return HTTP状态码 + */ + private static int getHttpStatus(ResultCode resultCode) { + return switch (resultCode) { + case ACCESS_UNAUTHORIZED, ACCESS_TOKEN_INVALID, REFRESH_TOKEN_INVALID -> HttpStatus.UNAUTHORIZED.value(); + default -> HttpStatus.BAD_REQUEST.value(); + }; + } + +} diff --git a/src/main/java/com/rnb/config/CaffeineConfig.java b/src/main/java/com/rnb/config/CaffeineConfig.java new file mode 100644 index 0000000..1cc12d3 --- /dev/null +++ b/src/main/java/com/rnb/config/CaffeineConfig.java @@ -0,0 +1,37 @@ +package com.rnb.config; + +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/rnb/config/CaptchaConfig.java b/src/main/java/com/rnb/config/CaptchaConfig.java new file mode 100644 index 0000000..70267fe --- /dev/null +++ b/src/main/java/com/rnb/config/CaptchaConfig.java @@ -0,0 +1,55 @@ +package com.rnb.config; + +import cn.hutool.captcha.generator.CodeGenerator; +import cn.hutool.captcha.generator.MathGenerator; +import cn.hutool.captcha.generator.RandomGenerator; +import com.rnb.config.property.CaptchaProperties; +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); + } 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/rnb/config/CorsConfig.java b/src/main/java/com/rnb/config/CorsConfig.java new file mode 100644 index 0000000..c00269b --- /dev/null +++ b/src/main/java/com/rnb/config/CorsConfig.java @@ -0,0 +1,42 @@ +package com.rnb.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/rnb/config/MailConfig.java b/src/main/java/com/rnb/config/MailConfig.java new file mode 100644 index 0000000..ceedeb5 --- /dev/null +++ b/src/main/java/com/rnb/config/MailConfig.java @@ -0,0 +1,51 @@ +package com.rnb.config; + +import com.rnb.config.property.MailProperties; +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/rnb/config/MybatisConfig.java b/src/main/java/com/rnb/config/MybatisConfig.java new file mode 100644 index 0000000..f75e226 --- /dev/null +++ b/src/main/java/com/rnb/config/MybatisConfig.java @@ -0,0 +1,48 @@ +package com.rnb.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.rnb.core.handler.MyDataPermissionHandler; +import com.rnb.core.handler.MyMetaObjectHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * mybatis-plus 配置类 + * + * @author Ray.Hao + * @since 2022/7/2 + */ +@Configuration +@EnableTransactionManagement +public class MybatisConfig { + + /** + * 分页插件和数据权限插件 + */ + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + //数据权限 + interceptor.addInnerInterceptor(new DataPermissionInterceptor(new MyDataPermissionHandler())); + //分页插件 + interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); + + return interceptor; + } + + /** + * 自动填充数据库创建人、创建时间、更新人、更新时间 + */ + @Bean + public GlobalConfig globalConfig() { + GlobalConfig globalConfig = new GlobalConfig(); + globalConfig.setMetaObjectHandler(new MyMetaObjectHandler()); + return globalConfig; + } + +} diff --git a/src/main/java/com/rnb/config/OpenApiConfig.java b/src/main/java/com/rnb/config/OpenApiConfig.java new file mode 100644 index 0000000..e1e629a --- /dev/null +++ b/src/main/java/com/rnb/config/OpenApiConfig.java @@ -0,0 +1,106 @@ +package com.rnb.config; + +import cn.hutool.core.util.ArrayUtil; +import com.rnb.config.property.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/rnb/config/PasswordEncoderConfig.java b/src/main/java/com/rnb/config/PasswordEncoderConfig.java new file mode 100644 index 0000000..0aa64ce --- /dev/null +++ b/src/main/java/com/rnb/config/PasswordEncoderConfig.java @@ -0,0 +1,24 @@ +package com.rnb.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/rnb/config/RedisCacheConfig.java b/src/main/java/com/rnb/config/RedisCacheConfig.java new file mode 100644 index 0000000..635c104 --- /dev/null +++ b/src/main/java/com/rnb/config/RedisCacheConfig.java @@ -0,0 +1,74 @@ +package com.rnb.config; + +import org.springframework.boot.autoconfigure.cache.CacheProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +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/rnb/config/RedisConfig.java b/src/main/java/com/rnb/config/RedisConfig.java new file mode 100644 index 0000000..0a755ae --- /dev/null +++ b/src/main/java/com/rnb/config/RedisConfig.java @@ -0,0 +1,49 @@ +package com.rnb.config; + +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.listener.RedisMessageListenerContainer; +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); + + redisTemplate.setKeySerializer(RedisSerializer.string()); + redisTemplate.setValueSerializer(RedisSerializer.json()); + + redisTemplate.setHashKeySerializer(RedisSerializer.string()); + redisTemplate.setHashValueSerializer(RedisSerializer.json()); + + redisTemplate.afterPropertiesSet(); + return redisTemplate; + } + + @Bean + public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory redisConnectionFactory){ + RedisMessageListenerContainer redisMessageListenerContainer = new RedisMessageListenerContainer(); + redisMessageListenerContainer.setConnectionFactory(redisConnectionFactory); + return redisMessageListenerContainer; + } +} diff --git a/src/main/java/com/rnb/config/SecurityConfig.java b/src/main/java/com/rnb/config/SecurityConfig.java new file mode 100644 index 0000000..0a0dd74 --- /dev/null +++ b/src/main/java/com/rnb/config/SecurityConfig.java @@ -0,0 +1,159 @@ +package com.rnb.config; + +import cn.binarywang.wx.miniapp.api.WxMaService; +import cn.hutool.captcha.generator.CodeGenerator; +import cn.hutool.core.util.ArrayUtil; +import com.rnb.config.property.SecurityProperties; +import com.rnb.core.filter.RateLimiterFilter; +import com.rnb.core.security.exception.MyAccessDeniedHandler; +import com.rnb.core.security.exception.MyAuthenticationEntryPoint; +import com.rnb.core.security.extension.sms.SmsAuthenticationProvider; +import com.rnb.core.security.extension.wechat.WechatAuthenticationProvider; +import com.rnb.core.security.filter.CaptchaValidationFilter; +import com.rnb.core.security.filter.TokenAuthenticationFilter; +import com.rnb.core.security.token.TokenManager; +import com.rnb.core.security.service.SysUserDetailsService; +import com.rnb.system.service.ConfigService; +import com.rnb.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 WxMaService wxMaService; + private final UserService userService; + private final SysUserDetailsService userDetailsService; + + private final CodeGenerator codeGenerator; + 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(redisTemplate, codeGenerator), 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(); + daoAuthenticationProvider.setPasswordEncoder(passwordEncoder); + daoAuthenticationProvider.setUserDetailsService(userDetailsService); + return daoAuthenticationProvider; + } + + /** + * 微信认证 Provider + */ + @Bean + public WechatAuthenticationProvider weChatAuthenticationProvider() { + return new WechatAuthenticationProvider(userService, wxMaService); + } + + + /** + * 短信验证码认证 Provider + */ + @Bean + public SmsAuthenticationProvider smsAuthenticationProvider() { + return new SmsAuthenticationProvider(userService, redisTemplate); + } + + /** + * 认证管理器 + */ + @Bean + public AuthenticationManager authenticationManager( + DaoAuthenticationProvider daoAuthenticationProvider, + WechatAuthenticationProvider weChatAuthenticationProvider, + SmsAuthenticationProvider smsAuthenticationProvider + ) { + return new ProviderManager( + daoAuthenticationProvider, + weChatAuthenticationProvider, + smsAuthenticationProvider + ); + } +} diff --git a/src/main/java/com/rnb/config/WebMvcConfig.java b/src/main/java/com/rnb/config/WebMvcConfig.java new file mode 100644 index 0000000..319b404 --- /dev/null +++ b/src/main/java/com/rnb/config/WebMvcConfig.java @@ -0,0 +1,93 @@ +package com.rnb.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.validator.HibernateValidator; +import org.springframework.beans.factory.config.AutowireCapableBeanFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.validation.beanvalidation.SpringConstraintValidatorFactory; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.math.BigInteger; +import java.text.SimpleDateFormat; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.TimeZone; + +/** + * Web 配置 + * + * @author Ray.Hao + * @since 2020/10/16 + */ +@Configuration +@Slf4j +public class WebMvcConfig implements WebMvcConfigurer { + + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + /** + * 配置消息转换器 + * + * @param converters 消息转换器列表 + */ + @Override + public void configureMessageConverters(List> converters) { + MappingJackson2HttpMessageConverter jackson2HttpMessageConverter = new MappingJackson2HttpMessageConverter(); + ObjectMapper objectMapper = new ObjectMapper(); + + // 注册 JavaTimeModule(替代手动注册 LocalDateTimeSerializer) + JavaTimeModule javaTimeModule = new JavaTimeModule(); + // 返回指定字符串格式 + javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DATE_TIME_FORMATTER)); + // 反序列化,接受前端传来的格式 + javaTimeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DATE_TIME_FORMATTER)); + objectMapper.registerModule(javaTimeModule); + + // 配置全局日期格式和时区 + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + objectMapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")); + objectMapper.setTimeZone(TimeZone.getTimeZone("GMT+8")); + + // 处理 Long/BigInteger 的精度问题 + SimpleModule simpleModule = new SimpleModule(); + simpleModule.addSerializer(Long.class, ToStringSerializer.instance); + simpleModule.addSerializer(BigInteger.class, ToStringSerializer.instance); + objectMapper.registerModule(simpleModule); + + jackson2HttpMessageConverter.setObjectMapper(objectMapper); + converters.add(1, jackson2HttpMessageConverter); + } + + /** + * 配置校验器 + * + * @param autowireCapableBeanFactory 用于注入 SpringConstraintValidatorFactory + * @return Validator 实例 + */ + @Bean + public Validator validator(final AutowireCapableBeanFactory autowireCapableBeanFactory) { + try (ValidatorFactory validatorFactory = Validation.byProvider(HibernateValidator.class) + .configure() + .failFast(true) // failFast=true 时,遇到第一个校验失败则立即返回,false 表示校验所有参数 + .constraintValidatorFactory(new SpringConstraintValidatorFactory(autowireCapableBeanFactory)) + .buildValidatorFactory()) { + + // 使用 try-with-resources 确保 ValidatorFactory 被正确关闭 + return validatorFactory.getValidator(); + } + } +} diff --git a/src/main/java/com/rnb/config/WebSocketConfig.java b/src/main/java/com/rnb/config/WebSocketConfig.java new file mode 100644 index 0000000..5b37e67 --- /dev/null +++ b/src/main/java/com/rnb/config/WebSocketConfig.java @@ -0,0 +1,169 @@ +package com.rnb.config; + +import cn.hutool.core.util.StrUtil; +import com.rnb.core.security.model.SysUserDetails; +import com.rnb.core.security.token.TokenManager; +import com.rnb.system.service.WebSocketService; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.http.HttpHeaders; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessagingException; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +/** + * WebSocket配置 + * + * @author Ray.Hao + * @since 3.0.0 + */ +@EnableWebSocketMessageBroker +@Configuration +@Slf4j +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final TokenManager tokenManager; + private final WebSocketService webSocketService; + + public WebSocketConfig(TokenManager tokenManager, @Lazy WebSocketService webSocketService) { + this.tokenManager = tokenManager; + this.webSocketService = webSocketService; + } + + /** + * 注册一个端点,客户端通过这个端点进行连接 + */ + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry + // 注册 /ws 的端点 + .addEndpoint("/ws") + // 允许跨域 + .setAllowedOriginPatterns("*"); + } + + + /** + * 配置消息代理 + */ + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + // 客户端发送消息的请求前缀 + registry.setApplicationDestinationPrefixes("/app"); + + // 客户端订阅消息的请求前缀,topic一般用于广播推送,queue用于点对点推送 + registry.enableSimpleBroker("/topic", "/queue"); + + // 服务端通知客户端的前缀,可以不设置,默认为user + registry.setUserDestinationPrefix("/user"); + } + + + /** + * 配置客户端入站通道拦截器 + *

+ * 核心功能: + * 1. 连接建立时解析令牌并绑定用户身份 + * 2. 连接关闭时触发下线通知 + * 3. 异常Token的防御性处理 + */ + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(new ChannelInterceptor() { + @Override + public Message preSend(@NotNull Message message, @NotNull MessageChannel channel) { + StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + if (accessor == null) { + return ChannelInterceptor.super.preSend(message, channel); + } + + try { + // 处理客户端连接请求 + if (StompCommand.CONNECT.equals(accessor.getCommand())) { + /* + * 安全校验流程: + * 1. 从HEADER中获取Authorization值 + * 2. 校验Bearer Token格式合法性 + * 3. 解析并验证JWT有效性 + * 4. 绑定用户身份到当前会话 + */ + String authorization = accessor.getFirstNativeHeader(HttpHeaders.AUTHORIZATION); + + // 防御性校验:确保Authorization头存在且格式正确 + if (StrUtil.isBlank(authorization) || !authorization.startsWith("Bearer ")) { + log.warn("非法连接请求:缺少有效的Authorization头"); + throw new AuthenticationCredentialsNotFoundException("Missing authorization header"); + } + + // 提取并处理JWT令牌(移除Bearer前缀) + String token = authorization.substring(7); + Authentication authentication = tokenManager.parseToken(token); + + // 令牌解析失败处理 + if (authentication == null) { + log.error("令牌解析失败:{}", token); + throw new BadCredentialsException("Invalid token"); + } + + // 获取用户详细信息 + SysUserDetails userDetails = (SysUserDetails) authentication.getPrincipal(); + if (userDetails == null || StrUtil.isBlank(userDetails.getUsername())) { + log.error("无效的用户凭证:{}", token); + throw new BadCredentialsException("Invalid user credentials"); + } + + String username = userDetails.getUsername(); + log.info("WebSocket连接建立:用户[{}]", username); + + // 绑定用户身份到当前会话(重要:用于@SendToUser等注解) + accessor.setUser(authentication); + + // 记录用户上线状态 + webSocketService.userConnected(username, accessor.getSessionId()); + + } + // 处理客户端断开请求 + else if (StompCommand.DISCONNECT.equals(accessor.getCommand())) { + /* + * 注意:只有成功建立过认证的连接才会触发下线事件 + * 防止未认证成功的连接产生脏数据 + */ + Authentication authentication = (Authentication) accessor.getUser(); + if (authentication != null && authentication.isAuthenticated()) { + String username = ((SysUserDetails) authentication.getPrincipal()).getUsername(); + log.info("WebSocket连接关闭:用户[{}]", username); + + // 记录用户下线状态 + webSocketService.userDisconnected(username); + } + } + } catch (AuthenticationException ex) { + // 认证失败时强制关闭连接 + log.error("连接认证失败:{}", ex.getMessage()); + throw ex; + } catch (Exception ex) { + // 捕获其他未知异常 + log.error("WebSocket连接处理异常:", ex); + throw new MessagingException("Connection processing failed"); + } + + return ChannelInterceptor.super.preSend(message, channel); + } + }); + } +} diff --git a/src/main/java/com/rnb/config/WxMiniAppConfig.java b/src/main/java/com/rnb/config/WxMiniAppConfig.java new file mode 100644 index 0000000..bacc0f3 --- /dev/null +++ b/src/main/java/com/rnb/config/WxMiniAppConfig.java @@ -0,0 +1,41 @@ +package com.rnb.config; + +import cn.binarywang.wx.miniapp.api.WxMaService; +import cn.binarywang.wx.miniapp.api.impl.WxMaServiceImpl; +import cn.binarywang.wx.miniapp.config.WxMaConfig; +import cn.binarywang.wx.miniapp.config.impl.WxMaDefaultConfigImpl; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 配置微信 appId 和 appSecret + * + * @author wangtao + * @since 2024/11/26 17:28 + */ +@Setter +@ConfigurationProperties(prefix = "wx.miniapp") +@Configuration +public class WxMiniAppConfig { + + private String appId; + + private String appSecret; + + @Bean + public WxMaConfig wxMaConfig() { + WxMaDefaultConfigImpl config = new WxMaDefaultConfigImpl(); + config.setAppid(appId); + config.setSecret(appSecret); + return config; + } + + @Bean + public WxMaService wxMaService(WxMaConfig wxMaConfig) { + WxMaService service = new WxMaServiceImpl(); + service.setWxMaConfig(wxMaConfig); + return service; + } +} diff --git a/src/main/java/com/rnb/config/XxlJobConfig.java b/src/main/java/com/rnb/config/XxlJobConfig.java new file mode 100644 index 0000000..01a84c8 --- /dev/null +++ b/src/main/java/com/rnb/config/XxlJobConfig.java @@ -0,0 +1,61 @@ +package com.rnb.config; + +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/rnb/config/property/AliyunSmsProperties.java b/src/main/java/com/rnb/config/property/AliyunSmsProperties.java new file mode 100644 index 0000000..85b5c1c --- /dev/null +++ b/src/main/java/com/rnb/config/property/AliyunSmsProperties.java @@ -0,0 +1,50 @@ +package com.rnb.config.property; + +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/rnb/config/property/CaptchaProperties.java b/src/main/java/com/rnb/config/property/CaptchaProperties.java new file mode 100644 index 0000000..4806500 --- /dev/null +++ b/src/main/java/com/rnb/config/property/CaptchaProperties.java @@ -0,0 +1,92 @@ +package com.rnb.config.property; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 验证码 属性配置 + * + * @author haoxr + * @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/rnb/config/property/CodegenProperties.java b/src/main/java/com/rnb/config/property/CodegenProperties.java new file mode 100644 index 0000000..89f6d63 --- /dev/null +++ b/src/main/java/com/rnb/config/property/CodegenProperties.java @@ -0,0 +1,96 @@ +package com.rnb.config.property; + +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.stereotype.Component; + +import java.util.List; +import java.util.Map; + +/** + * 代码生成配置属性 + * + * @author Ray + * @since 2.11.0 + */ +@Component +@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/rnb/config/property/MailProperties.java b/src/main/java/com/rnb/config/property/MailProperties.java new file mode 100644 index 0000000..fd99a68 --- /dev/null +++ b/src/main/java/com/rnb/config/property/MailProperties.java @@ -0,0 +1,89 @@ +package com.rnb.config.property; + +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/rnb/config/property/SecurityProperties.java b/src/main/java/com/rnb/config/property/SecurityProperties.java new file mode 100644 index 0000000..c30a0c2 --- /dev/null +++ b/src/main/java/com/rnb/config/property/SecurityProperties.java @@ -0,0 +1,112 @@ +package com.rnb.config.property; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +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 + 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/rnb/core/aspect/LogAspect.java b/src/main/java/com/rnb/core/aspect/LogAspect.java new file mode 100644 index 0000000..0ddbea3 --- /dev/null +++ b/src/main/java/com/rnb/core/aspect/LogAspect.java @@ -0,0 +1,226 @@ +package com.rnb.core.aspect; + +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.date.TimeInterval; +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.digest.DigestUtil; +import cn.hutool.http.useragent.UserAgent; +import cn.hutool.http.useragent.UserAgentUtil; +import cn.hutool.json.JSONUtil; +import com.aliyun.oss.HttpMethod; +import com.rnb.common.enums.LogModuleEnum; +import com.rnb.common.util.IPUtils; +import com.rnb.core.security.util.SecurityUtils; +import com.rnb.system.model.entity.Log; +import com.rnb.system.service.LogService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.*; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.HandlerMapping; + +import java.util.Collection; +import java.util.Map; +import java.util.Objects; + +/** + * 日志切面 + * + * @author Ray.Hao + * @since 2024/6/25 + */ +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class LogAspect { + private final LogService logService; + private final HttpServletRequest request; + private final CacheManager cacheManager; + + /** + * 切点 + */ + @Pointcut("@annotation(com.rnb.common.annotation.Log)") + public void logPointcut() { + } + + /** + * 处理完请求后执行 + * + * @param joinPoint 切点 + */ + @Around("logPointcut() && @annotation(logAnnotation)") + public Object doAround(ProceedingJoinPoint joinPoint, com.rnb.common.annotation.Log logAnnotation) throws Throwable { + TimeInterval timer = DateUtil.timer(); + Object result = null; + Exception exception = null; + + try { + result = joinPoint.proceed(); + } catch (Exception e) { + exception = e; + throw e; + } finally { + long executionTime = timer.interval(); // 执行时长 + this.saveLog(joinPoint, exception, result, logAnnotation, executionTime); + } + return result; + } + + + /** + * 保存日志 + * + * @param joinPoint 切点 + * @param e 异常 + * @param jsonResult 响应结果 + * @param logAnnotation 日志注解 + */ + private void saveLog(final JoinPoint joinPoint, final Exception e, Object jsonResult, com.rnb.common.annotation.Log logAnnotation, long executionTime) { + String requestURI = request.getRequestURI(); + // 创建日志记录 + Log log = new Log(); + log.setExecutionTime(executionTime); + if (logAnnotation == null && e != null) { + log.setModule(LogModuleEnum.EXCEPTION); + log.setContent("系统发生异常"); + this.setRequestParameters(joinPoint, log); + log.setResponseContent(JSONUtil.toJsonStr(e.getStackTrace())); + } else { + log.setModule(logAnnotation.module()); + log.setContent(logAnnotation.value()); + // 请求参数 + if (logAnnotation.params()) { + this.setRequestParameters(joinPoint, log); + } + // 响应结果 + if (logAnnotation.result() && jsonResult != null) { + log.setResponseContent(JSONUtil.toJsonStr(jsonResult)); + } + } + log.setRequestUri(requestURI); + Long userId = SecurityUtils.getUserId(); + log.setCreateBy(userId); + String ipAddr = IPUtils.getIpAddr(request); + if (StrUtil.isNotBlank(ipAddr)) { + log.setIp(ipAddr); + String region = IPUtils.getRegion(ipAddr); + // 中国|0|四川省|成都市|电信 解析省和市 + if (StrUtil.isNotBlank(region)) { + String[] regionArray = region.split("\\|"); + if (regionArray.length > 2) { + log.setProvince(regionArray[2]); + log.setCity(regionArray[3]); + } + } + } + + + // 获取浏览器和终端系统信息 + String userAgentString = request.getHeader("User-Agent"); + UserAgent userAgent = resolveUserAgent(userAgentString); + if (Objects.nonNull(userAgent)) { + // 系统信息 + log.setOs(userAgent.getOs().getName()); + // 浏览器信息 + log.setBrowser(userAgent.getBrowser().getName()); + log.setBrowserVersion(userAgent.getBrowser().getVersion(userAgentString)); + } + // 保存日志到数据库 + logService.save(log); + } + + /** + * 设置请求参数到日志对象中 + * + * @param joinPoint 切点 + * @param log 操作日志 + */ + private void setRequestParameters(JoinPoint joinPoint, Log log) { + String requestMethod = request.getMethod(); + log.setRequestMethod(requestMethod); + if (HttpMethod.GET.name().equalsIgnoreCase(requestMethod) || HttpMethod.PUT.name().equalsIgnoreCase(requestMethod) || HttpMethod.POST.name().equalsIgnoreCase(requestMethod)) { + String params = convertArgumentsToString(joinPoint.getArgs()); + log.setRequestParams(StrUtil.sub(params, 0, 65535)); + } else { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes != null) { + Map paramsMap = (Map) attributes.getRequest().getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE); + log.setRequestParams(StrUtil.sub(paramsMap.toString(), 0, 65535)); + } else { + log.setRequestParams(""); + } + } + } + + /** + * 将参数数组转换为字符串 + * + * @param paramsArray 参数数组 + * @return 参数字符串 + */ + private String convertArgumentsToString(Object[] paramsArray) { + StringBuilder params = new StringBuilder(); + if (paramsArray != null) { + for (Object param : paramsArray) { + if (!shouldFilterObject(param)) { + params.append(JSONUtil.toJsonStr(param)).append(" "); + } + } + } + return params.toString().trim(); + } + + /** + * 判断是否需要过滤的对象。 + * + * @param obj 对象信息。 + * @return 如果是需要过滤的对象,则返回true;否则返回false。 + */ + private boolean shouldFilterObject(Object obj) { + Class clazz = obj.getClass(); + if (clazz.isArray()) { + return MultipartFile.class.isAssignableFrom(clazz.getComponentType()); + } else if (Collection.class.isAssignableFrom(clazz)) { + Collection collection = (Collection) obj; + return collection.stream().anyMatch(item -> item instanceof MultipartFile); + } else if (Map.class.isAssignableFrom(clazz)) { + Map map = (Map) obj; + return map.values().stream().anyMatch(value -> value instanceof MultipartFile); + } + return obj instanceof MultipartFile || obj instanceof HttpServletRequest || obj instanceof HttpServletResponse; + } + + + /** + * 解析UserAgent + * + * @param userAgentString UserAgent字符串 + * @return UserAgent + */ + public UserAgent resolveUserAgent(String userAgentString) { + if (StrUtil.isBlank(userAgentString)) { + return null; + } + // 给userAgentStringMD5加密一次防止过长 + String userAgentStringMD5 = DigestUtil.md5Hex(userAgentString); + //判断是否命中缓存 + UserAgent userAgent = Objects.requireNonNull(cacheManager.getCache("userAgent")).get(userAgentStringMD5, UserAgent.class); + if (userAgent != null) { + return userAgent; + } + userAgent = UserAgentUtil.parse(userAgentString); + Objects.requireNonNull(cacheManager.getCache("userAgent")).put(userAgentStringMD5, userAgent); + return userAgent; + } + +} diff --git a/src/main/java/com/rnb/core/aspect/RepeatSubmitAspect.java b/src/main/java/com/rnb/core/aspect/RepeatSubmitAspect.java new file mode 100644 index 0000000..b450e74 --- /dev/null +++ b/src/main/java/com/rnb/core/aspect/RepeatSubmitAspect.java @@ -0,0 +1,102 @@ +package com.rnb.core.aspect; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.digest.DigestUtil; +import com.rnb.common.constant.RedisConstants; +import com.rnb.common.constant.SecurityConstants; +import com.rnb.common.result.ResultCode; +import com.rnb.common.exception.BusinessException; +import com.rnb.common.annotation.RepeatSubmit; +import com.rnb.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.USER_DUPLICATE_REQUEST); + } + 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/rnb/core/filter/RateLimiterFilter.java b/src/main/java/com/rnb/core/filter/RateLimiterFilter.java new file mode 100644 index 0000000..f49f9e2 --- /dev/null +++ b/src/main/java/com/rnb/core/filter/RateLimiterFilter.java @@ -0,0 +1,98 @@ +package com.rnb.core.filter; + +import cn.hutool.core.convert.Convert; +import cn.hutool.core.util.StrUtil; +import com.rnb.common.constant.RedisConstants; +import com.rnb.common.constant.SystemConstants; +import com.rnb.common.result.ResultCode; +import com.rnb.common.util.IPUtils; +import com.rnb.common.util.ResponseUtils; +import com.rnb.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)) { + // 返回限流错误信息 + ResponseUtils.writeErrMsg(response, ResultCode.REQUEST_CONCURRENCY_LIMIT_EXCEEDED); + return; + } + + // 未触发限流,继续执行过滤器链 + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/rnb/core/filter/RequestLogFilter.java b/src/main/java/com/rnb/core/filter/RequestLogFilter.java new file mode 100644 index 0000000..3232060 --- /dev/null +++ b/src/main/java/com/rnb/core/filter/RequestLogFilter.java @@ -0,0 +1,38 @@ +package com.rnb.core.filter; + +import com.rnb.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/rnb/core/handler/MyDataPermissionHandler.java b/src/main/java/com/rnb/core/handler/MyDataPermissionHandler.java new file mode 100644 index 0000000..586ab50 --- /dev/null +++ b/src/main/java/com/rnb/core/handler/MyDataPermissionHandler.java @@ -0,0 +1,115 @@ +package com.rnb.core.handler; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.toolkit.StringPool; +import com.baomidou.mybatisplus.extension.plugins.handler.DataPermissionHandler; +import com.rnb.common.annotation.DataPermission; +import com.rnb.common.base.IBaseEnum; +import com.rnb.common.enums.DataScopeEnum; +import com.rnb.core.security.util.SecurityUtils; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.operators.conditional.AndExpression; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; + +import java.lang.reflect.Method; + +/** + * 数据权限控制器 + * + * @author zc + * @since 2021-12-10 13:28 + */ +@Slf4j +public class MyDataPermissionHandler implements DataPermissionHandler { + + /** + * 获取数据权限的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; + } + // 获取当前用户的数据权限 + Integer dataScope = SecurityUtils.getDataScope(); + DataScopeEnum dataScopeEnum = IBaseEnum.getEnumByValue(dataScope, DataScopeEnum.class); + // 如果是全部数据权限,直接返回 + if (DataScopeEnum.ALL.equals(dataScopeEnum)) { + 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 dataScopeFilter(annotation.deptAlias(), annotation.deptIdColumnName(), annotation.userAlias(), annotation.userIdColumnName(), dataScopeEnum,where); + } + } + return where; + } + + /** + * 构建过滤条件 + * + * @param where 当前查询条件 + * @return 构建后查询条件 + */ + @SneakyThrows + public static Expression dataScopeFilter(String deptAlias, String deptIdColumnName, String userAlias, String userIdColumnName,DataScopeEnum dataScopeEnum, Expression where) { + + // 获取部门和用户的别名 + String deptColumnName = StrUtil.isNotBlank(deptAlias) ? (deptAlias + StringPool.DOT + deptIdColumnName) : deptIdColumnName; + String userColumnName = StrUtil.isNotBlank(userAlias) ? (userAlias + StringPool.DOT + userIdColumnName) : userIdColumnName; + + Long deptId, userId; + String appendSqlStr; + switch (dataScopeEnum) { + case ALL: + return where; + case DEPT: + deptId = SecurityUtils.getDeptId(); + appendSqlStr = deptColumnName + StringPool.EQUALS + deptId; + break; + case SELF: + userId = SecurityUtils.getUserId(); + appendSqlStr = userColumnName + StringPool.EQUALS + userId; + break; + // 默认部门及子部门数据权限 + default: + deptId = SecurityUtils.getDeptId(); + appendSqlStr = deptColumnName + " IN ( SELECT id FROM sys_dept WHERE id = " + deptId + " OR FIND_IN_SET( " + deptId + " , tree_path ) )"; + break; + } + + if (StrUtil.isBlank(appendSqlStr)) { + return where; + } + + Expression appendExpression = CCJSqlParserUtil.parseCondExpression(appendSqlStr); + + if (where == null) { + return appendExpression; + } + + return new AndExpression(where, appendExpression); + } + + +} + diff --git a/src/main/java/com/rnb/core/handler/MyMetaObjectHandler.java b/src/main/java/com/rnb/core/handler/MyMetaObjectHandler.java new file mode 100644 index 0000000..57cf5b1 --- /dev/null +++ b/src/main/java/com/rnb/core/handler/MyMetaObjectHandler.java @@ -0,0 +1,39 @@ +package com.rnb.core.handler; + +import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; +import org.apache.ibatis.reflection.MetaObject; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +/** + * mybatis-plus 字段自动填充 + * + * @author haoxr + * @since 2022/10/14 + */ +@Component +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/rnb/core/security/exception/CaptchaValidationException.java b/src/main/java/com/rnb/core/security/exception/CaptchaValidationException.java new file mode 100644 index 0000000..ba55887 --- /dev/null +++ b/src/main/java/com/rnb/core/security/exception/CaptchaValidationException.java @@ -0,0 +1,15 @@ +package com.rnb.core.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/rnb/core/security/exception/MyAccessDeniedHandler.java b/src/main/java/com/rnb/core/security/exception/MyAccessDeniedHandler.java new file mode 100644 index 0000000..599de74 --- /dev/null +++ b/src/main/java/com/rnb/core/security/exception/MyAccessDeniedHandler.java @@ -0,0 +1,24 @@ +package com.rnb.core.security.exception; + +import com.rnb.common.result.ResultCode; +import com.rnb.common.util.ResponseUtils; +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) { + ResponseUtils.writeErrMsg(response, ResultCode.ACCESS_UNAUTHORIZED); + } + +} diff --git a/src/main/java/com/rnb/core/security/exception/MyAuthenticationEntryPoint.java b/src/main/java/com/rnb/core/security/exception/MyAuthenticationEntryPoint.java new file mode 100644 index 0000000..51d1677 --- /dev/null +++ b/src/main/java/com/rnb/core/security/exception/MyAuthenticationEntryPoint.java @@ -0,0 +1,48 @@ +package com.rnb.core.security.exception; + +import com.rnb.common.result.ResultCode; +import com.rnb.common.util.ResponseUtils; +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) { + // 用户名或密码错误 + ResponseUtils.writeErrMsg(response, ResultCode.USER_PASSWORD_ERROR); + } else if(authException instanceof InsufficientAuthenticationException){ + // 请求头缺失Authorization、Token格式错误、Token过期、签名验证失败 + ResponseUtils.writeErrMsg(response, ResultCode.ACCESS_TOKEN_INVALID); + } else { + // 其他未明确处理的认证异常(如账户被锁定、账户禁用等) + ResponseUtils.writeErrMsg(response, ResultCode.USER_LOGIN_EXCEPTION, authException.getMessage()); + } + } +} + + + + diff --git a/src/main/java/com/rnb/core/security/extension/sms/SmsAuthenticationProvider.java b/src/main/java/com/rnb/core/security/extension/sms/SmsAuthenticationProvider.java new file mode 100644 index 0000000..b3d8d49 --- /dev/null +++ b/src/main/java/com/rnb/core/security/extension/sms/SmsAuthenticationProvider.java @@ -0,0 +1,88 @@ +package com.rnb.core.security.extension.sms; + +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import com.rnb.common.constant.RedisConstants; +import com.rnb.core.security.exception.CaptchaValidationException; +import com.rnb.core.security.model.SysUserDetails; +import com.rnb.core.security.model.UserAuthCredentials; +import com.rnb.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 + * + * @author Ray.Hao + * @since 2.17.0 + */ +@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; + } + + /** + * 短信验证码认证逻辑,参考 Spring Security 认证密码校验流程 + * + * @param authentication 认证对象 + * @return 认证后的 Authentication 对象 + * @throws AuthenticationException 认证异常 + * @see org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider#authenticate(Authentication) + */ + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + String mobile = (String) authentication.getPrincipal(); + String inputVerifyCode = (String) authentication.getCredentials(); + + // 根据手机号获取用户信息 + UserAuthCredentials userAuthCredentials = userService.getAuthCredentialsByMobile(mobile); + + if (userAuthCredentials == null) { + throw new UsernameNotFoundException("用户不存在"); + } + + // 检查用户状态是否有效 + if (ObjectUtil.notEqual(userAuthCredentials.getStatus(), 1)) { + throw new DisabledException("用户已被禁用"); + } + + // 校验发送短信验证码的手机号是否与当前登录用户一致 + String cacheKey = StrUtil.format(RedisConstants.Captcha.SMS_LOGIN_CODE, mobile); + String cachedVerifyCode = (String) redisTemplate.opsForValue().get(cacheKey); + + if (!StrUtil.equals(inputVerifyCode, cachedVerifyCode)) { + throw new CaptchaValidationException("验证码错误"); + } else { + // 验证成功后删除验证码 + redisTemplate.delete(cacheKey); + } + + // 构建认证后的用户详情信息 + SysUserDetails userDetails = new SysUserDetails(userAuthCredentials); + + // 创建已认证的 SmsAuthenticationToken + return SmsAuthenticationToken.authenticated( + userDetails, + userDetails.getAuthorities() + ); + } + + @Override + public boolean supports(Class authentication) { + return SmsAuthenticationToken.class.isAssignableFrom(authentication); + } +} diff --git a/src/main/java/com/rnb/core/security/extension/sms/SmsAuthenticationToken.java b/src/main/java/com/rnb/core/security/extension/sms/SmsAuthenticationToken.java new file mode 100644 index 0000000..1567661 --- /dev/null +++ b/src/main/java/com/rnb/core/security/extension/sms/SmsAuthenticationToken.java @@ -0,0 +1,78 @@ +package com.rnb.core.security.extension.sms; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import java.io.Serial; +import java.util.Collection; + +/** + * 短信验证码认证 Token + * + * @author Ray.Hao + * @since 2.20.0 + */ +public class SmsAuthenticationToken extends AbstractAuthenticationToken { + @Serial + private static final long serialVersionUID = 621L; + + /** + * 认证信息 (手机号) + */ + private final Object principal; + + /** + * 凭证信息 (短信验证码) + */ + private final Object credentials; + + /** + * 短信验证码认证 Token (未认证) + * + * @param principal 微信用户信息 + */ + public SmsAuthenticationToken(Object principal, Object credentials) { + // 没有授权信息时,设置为 null + super(null); + this.principal = principal; + this.credentials = credentials; + // 默认未认证 + this.setAuthenticated(false); + } + + /** + * 短信验证码认证 Token (已认证) + * + * @param principal 用户信息 + * @param authorities 授权信息 + */ + public SmsAuthenticationToken(Object principal, Collection authorities) { + super(authorities); + this.principal = principal; + this.credentials = null; + // 认证通过 + super.setAuthenticated(true); + } + + + /** + * 认证通过 + * + * @param principal 用户信息 + * @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/rnb/core/security/extension/wechat/WechatAuthenticationProvider.java b/src/main/java/com/rnb/core/security/extension/wechat/WechatAuthenticationProvider.java new file mode 100644 index 0000000..9e5bf15 --- /dev/null +++ b/src/main/java/com/rnb/core/security/extension/wechat/WechatAuthenticationProvider.java @@ -0,0 +1,99 @@ +package com.rnb.core.security.extension.wechat; + +import cn.binarywang.wx.miniapp.api.WxMaService; +import cn.binarywang.wx.miniapp.bean.WxMaJscode2SessionResult; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import com.rnb.core.security.model.SysUserDetails; +import com.rnb.core.security.model.UserAuthCredentials; +import com.rnb.system.service.UserService; +import lombok.extern.slf4j.Slf4j; +import me.chanjar.weixin.common.error.WxErrorException; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.CredentialsExpiredException; +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 + * + * @author Ray.Hao + * @since 2.17.0 + */ +@Slf4j +public class WechatAuthenticationProvider implements AuthenticationProvider { + + private final UserService userService; + + private final WxMaService wxMaService; + + + public WechatAuthenticationProvider(UserService userService, WxMaService wxMaService) { + this.userService = userService; + this.wxMaService = wxMaService; + } + + + /** + * 微信认证逻辑,参考 Spring Security 认证密码校验流程 + * + * @param authentication 认证对象 + * @return 认证后的 Authentication 对象 + * @throws AuthenticationException 认证异常 + * @see org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider#authenticate(Authentication) + */ + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + String code = (String) authentication.getPrincipal(); + + // 通过微信服务端验证 code 并获取用户会话信息 + WxMaJscode2SessionResult sessionInfo; + try { + sessionInfo = wxMaService.getUserService().getSessionInfo(code); + } catch (WxErrorException e) { + throw new CredentialsExpiredException("微信登录 code 无效或已失效,请重新获取"); + } + + String openId = sessionInfo.getOpenid(); + if (StrUtil.isBlank(openId)) { + throw new UsernameNotFoundException("未能获取到微信 OpenID,请稍后重试"); + } + + // 根据微信 OpenID 查询用户信息 + UserAuthCredentials userAuthCredentials = userService.getAuthCredentialsByOpenId(openId); + + if (userAuthCredentials == null) { + // TODO: 用户不存在则注册,这里需要获取用户手机号并与现有用户绑定 + userService.registerOrBindWechatUser(openId); + + // 再次查询用户信息,确保用户注册成功 + userAuthCredentials = userService.getAuthCredentialsByOpenId(openId); + if (userAuthCredentials == null) { + throw new UsernameNotFoundException("用户注册失败,请稍后重试"); + } + } + + // 检查用户状态是否有效 + if (ObjectUtil.notEqual(userAuthCredentials.getStatus(), 1)) { + throw new DisabledException("用户已被禁用"); + } + // 这里因为已经根据 code 从微信小程序获取到 openid 不需要再经过系统认证,所以直接生成 + + // 构建认证后的用户详情信息 + SysUserDetails userDetails = new SysUserDetails(userAuthCredentials); + + // 创建已认证的 WeChatAuthenticationToken + return WechatAuthenticationToken.authenticated( + userDetails, + userDetails.getAuthorities() + ); + } + + @Override + public boolean supports(Class authentication) { + return WechatAuthenticationToken.class.isAssignableFrom(authentication); + } +} diff --git a/src/main/java/com/rnb/core/security/extension/wechat/WechatAuthenticationToken.java b/src/main/java/com/rnb/core/security/extension/wechat/WechatAuthenticationToken.java new file mode 100644 index 0000000..932913b --- /dev/null +++ b/src/main/java/com/rnb/core/security/extension/wechat/WechatAuthenticationToken.java @@ -0,0 +1,69 @@ +package com.rnb.core.security.extension.wechat; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import java.io.Serial; +import java.util.Collection; + +/** + * 微信认证 Token + * + * @author Ray.Hao + * @since 2024/12/2 + */ +public class WechatAuthenticationToken extends AbstractAuthenticationToken { + @Serial + private static final long serialVersionUID = 621L; + private final Object principal; + + /** + * 微信认证 Token (未认证) + * + * @param principal 微信用户信息 + */ + public WechatAuthenticationToken(Object principal) { + // 没有授权信息时,设置为 null + super(null); + this.principal = principal; + // 默认未认证 + this.setAuthenticated(false); + } + + + /** + * 微信认证 Token (已认证) + * + * @param principal 微信用户信息 + * @param authorities 授权信息 + */ + public WechatAuthenticationToken(Object principal, Collection authorities) { + super(authorities); + this.principal = principal; + // 认证通过 + super.setAuthenticated(true); + } + + + /** + * 认证通过 + * + * @param principal 微信用户信息 + * @param authorities 授权信息 + * @return + */ + public static WechatAuthenticationToken authenticated(Object principal, Collection authorities) { + return new WechatAuthenticationToken(principal, authorities); + } + + @Override + public Object getCredentials() { + // 微信认证不需要密码 + return null; + } + + @Override + public Object getPrincipal() { + return this.principal; + } +} diff --git a/src/main/java/com/rnb/core/security/filter/CaptchaValidationFilter.java b/src/main/java/com/rnb/core/security/filter/CaptchaValidationFilter.java new file mode 100644 index 0000000..e6f77ab --- /dev/null +++ b/src/main/java/com/rnb/core/security/filter/CaptchaValidationFilter.java @@ -0,0 +1,76 @@ +package com.rnb.core.security.filter; + +import cn.hutool.captcha.generator.CodeGenerator; +import cn.hutool.core.util.StrUtil; +import com.rnb.common.constant.RedisConstants; +import com.rnb.common.constant.SecurityConstants; +import com.rnb.common.result.ResultCode; +import com.rnb.common.util.ResponseUtils; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpMethod; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + + +/** + * 图形验证码校验过滤器 + * + * @author haoxr + * @since 2022/10/1 + */ +public class CaptchaValidationFilter extends OncePerRequestFilter { + + private static final AntPathRequestMatcher LOGIN_PATH_REQUEST_MATCHER = new AntPathRequestMatcher(SecurityConstants.LOGIN_PATH, HttpMethod.POST.name()); + + public static final String CAPTCHA_CODE_PARAM_NAME = "captchaCode"; + public static final String CAPTCHA_KEY_PARAM_NAME = "captchaKey"; + + private final RedisTemplate redisTemplate; + + private final CodeGenerator codeGenerator; + + public CaptchaValidationFilter(RedisTemplate redisTemplate, CodeGenerator codeGenerator) { + this.redisTemplate = redisTemplate; + this.codeGenerator = codeGenerator; + } + + + @Override + public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { + // 检验登录接口的验证码 + if (LOGIN_PATH_REQUEST_MATCHER.matches(request)) { + // 请求中的验证码 + String captchaCode = request.getParameter(CAPTCHA_CODE_PARAM_NAME); + // TODO 兼容没有验证码的版本(线上请移除这个判断) + if (StrUtil.isBlank(captchaCode)) { + chain.doFilter(request, response); + return; + } + // 缓存中的验证码 + String verifyCodeKey = request.getParameter(CAPTCHA_KEY_PARAM_NAME); + String cacheVerifyCode = (String) redisTemplate.opsForValue().get( + StrUtil.format(RedisConstants.Captcha.IMAGE_CODE, verifyCodeKey) + ); + if (cacheVerifyCode == null) { + ResponseUtils.writeErrMsg(response, ResultCode.USER_VERIFICATION_CODE_EXPIRED); + } else { + // 验证码比对 + if (codeGenerator.verify(cacheVerifyCode, captchaCode)) { + chain.doFilter(request, response); + } else { + ResponseUtils.writeErrMsg(response, ResultCode.USER_VERIFICATION_CODE_ERROR); + } + } + } else { + // 非登录接口放行 + chain.doFilter(request, response); + } + } + +} diff --git a/src/main/java/com/rnb/core/security/filter/TokenAuthenticationFilter.java b/src/main/java/com/rnb/core/security/filter/TokenAuthenticationFilter.java new file mode 100644 index 0000000..385b78b --- /dev/null +++ b/src/main/java/com/rnb/core/security/filter/TokenAuthenticationFilter.java @@ -0,0 +1,73 @@ +package com.rnb.core.security.filter; + +import cn.hutool.core.util.StrUtil; +import com.rnb.common.constant.SecurityConstants; +import com.rnb.common.result.ResultCode; +import com.rnb.common.util.ResponseUtils; +import com.rnb.core.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 authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + + try { + if (StrUtil.isNotBlank(authorizationHeader) + && authorizationHeader.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) { + + // 剥离Bearer前缀获取原始令牌 + String rawToken = authorizationHeader.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length()); + + // 执行令牌有效性检查(包含密码学验签和过期时间验证) + boolean isValidToken = tokenManager.validateToken(rawToken); + if (!isValidToken) { + ResponseUtils.writeErrMsg(response, ResultCode.ACCESS_TOKEN_INVALID); + return; + } + + // 将令牌解析为 Spring Security 上下文认证对象 + Authentication authentication = tokenManager.parseToken(rawToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception ex) { + // 安全上下文清除保障(防止上下文残留) + SecurityContextHolder.clearContext(); + ResponseUtils.writeErrMsg(response, ResultCode.ACCESS_TOKEN_INVALID); + return; + } + + // 继续后续过滤器链执行 + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/rnb/core/security/model/AuthenticationToken.java b/src/main/java/com/rnb/core/security/model/AuthenticationToken.java new file mode 100644 index 0000000..8f0f5c2 --- /dev/null +++ b/src/main/java/com/rnb/core/security/model/AuthenticationToken.java @@ -0,0 +1,30 @@ +package com.rnb.core.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/rnb/core/security/model/OnlineUser.java b/src/main/java/com/rnb/core/security/model/OnlineUser.java new file mode 100644 index 0000000..91c30e1 --- /dev/null +++ b/src/main/java/com/rnb/core/security/model/OnlineUser.java @@ -0,0 +1,46 @@ +package com.rnb.core.security.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Set; + +/** + * 在线用户信息对象 + * + * @author wangtao + * @since 2025/2/27 10:31 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class OnlineUser { + + /** + * 用户ID + */ + private Long userId; + + /** + * 用户名 + */ + private String username; + + /** + * 部门ID + */ + private Long deptId; + + /** + * 数据权限范围 + *

定义用户可访问的数据范围,如全部、本部门或自定义范围

+ */ + private Integer dataScope; + + /** + * 角色权限集合 + */ + private Set roles; + +} diff --git a/src/main/java/com/rnb/core/security/model/SysUserDetails.java b/src/main/java/com/rnb/core/security/model/SysUserDetails.java new file mode 100644 index 0000000..cf36e12 --- /dev/null +++ b/src/main/java/com/rnb/core/security/model/SysUserDetails.java @@ -0,0 +1,106 @@ +package com.rnb.core.security.model; + +import cn.hutool.core.collection.CollectionUtil; +import cn.hutool.core.util.ObjectUtil; +import com.rnb.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.Collection; +import java.util.Collections; +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 Integer dataScope; + + /** + * 用户角色权限集合 + */ + private Collection authorities; + + /** + * 构造函数:根据用户认证信息初始化用户详情对象 + * + * @param user 用户认证信息对象 {@link UserAuthCredentials} + */ + public SysUserDetails(UserAuthCredentials 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.dataScope = user.getDataScope(); + + // 初始化角色权限集合 + 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; + } +} diff --git a/src/main/java/com/rnb/core/security/model/UserAuthCredentials.java b/src/main/java/com/rnb/core/security/model/UserAuthCredentials.java new file mode 100644 index 0000000..b596fd5 --- /dev/null +++ b/src/main/java/com/rnb/core/security/model/UserAuthCredentials.java @@ -0,0 +1,58 @@ +package com.rnb.core.security.model; + +import com.rnb.common.enums.DataScopeEnum; +import lombok.Data; +import java.util.Set; + +/** + * 用户认证凭证信息 + * + * @author Ray.Hao + * @since 2022/10/22 + */ +@Data +public class UserAuthCredentials { + + /** + * 用户ID + */ + private Long userId; + + /** + * 用户名 + */ + private String username; + + /** + * 昵称 + */ + private String nickname; + + /** + * 部门ID + */ + private Long deptId; + + /** + * 用户密码 + */ + private String password; + + /** + * 状态(1:启用;0:禁用) + */ + private Integer status; + + /** + * 用户所属的角色集合 + */ + private Set roles; + + /** + * 数据权限范围,用于控制用户可以访问的数据级别 + * + * @see DataScopeEnum + */ + private Integer dataScope; + +} diff --git a/src/main/java/com/rnb/core/security/service/PermissionService.java b/src/main/java/com/rnb/core/security/service/PermissionService.java new file mode 100644 index 0000000..261b75c --- /dev/null +++ b/src/main/java/com/rnb/core/security/service/PermissionService.java @@ -0,0 +1,97 @@ +package com.rnb.core.security.service; + +import cn.hutool.core.collection.CollectionUtil; +import cn.hutool.core.util.StrUtil; +import com.rnb.common.constant.RedisConstants; +import com.rnb.core.security.util.SecurityUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.util.PatternMatchUtils; + +import java.util.*; + +/** + * SpringSecurity 权限校验 + * + * @author haoxr + * @since 2022/2/22 + */ +@Component("ss") +@RequiredArgsConstructor +@Slf4j +public class PermissionService { + + private final RedisTemplate redisTemplate; + + /** + * 判断当前登录用户是否拥有操作权限 + * + * @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 = this.getRolePermsFormCache(roleCodes); + if (CollectionUtil.isEmpty(rolePerms)) { + return false; + } + // 判断当前登录用户的所有角色的权限列表中是否包含所需权限 + boolean hasPermission = rolePerms.stream() + .anyMatch(rolePerm -> + // 匹配权限,支持通配符(* 等) + PatternMatchUtils.simpleMatch(rolePerm, requiredPerm) + ); + + if (!hasPermission) { + log.error("用户无操作权限:{}",requiredPerm); + } + return hasPermission; + } + + + /** + * 从缓存中获取角色权限列表 + * + * @param roleCodes 角色编码集合 + * @return 角色权限列表 + */ + public Set getRolePermsFormCache(Set roleCodes) { + // 检查输入是否为空 + if (CollectionUtil.isEmpty(roleCodes)) { + return Collections.emptySet(); + } + + Set perms = new HashSet<>(); + // 从缓存中一次性获取所有角色的权限 + Collection roleCodesAsObjects = new ArrayList<>(roleCodes); + List rolePermsList = redisTemplate.opsForHash().multiGet(RedisConstants.System.ROLE_PERMS, roleCodesAsObjects); + + for (Object rolePermsObj : rolePermsList) { + if (rolePermsObj instanceof Set) { + @SuppressWarnings("unchecked") + Set rolePerms = (Set) rolePermsObj; + perms.addAll(rolePerms); + } + } + + return perms; + } + +} diff --git a/src/main/java/com/rnb/core/security/service/SysUserDetailsService.java b/src/main/java/com/rnb/core/security/service/SysUserDetailsService.java new file mode 100644 index 0000000..8c68a7b --- /dev/null +++ b/src/main/java/com/rnb/core/security/service/SysUserDetailsService.java @@ -0,0 +1,48 @@ +package com.rnb.core.security.service; + +import com.rnb.core.security.model.SysUserDetails; +import com.rnb.core.security.model.UserAuthCredentials; +import com.rnb.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; + + /** + * 根据用户名获取用户信息 + * + * @param username 用户名 + * @return 用户信息 + * @throws UsernameNotFoundException 用户名未找到异常 + */ + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + try { + UserAuthCredentials userAuthCredentials = userService.getAuthCredentialsByUsername(username); + if (userAuthCredentials == null) { + throw new UsernameNotFoundException(username); + } + return new SysUserDetails(userAuthCredentials); + } catch (Exception e) { + // 记录异常日志 + log.error("认证异常:{}", e.getMessage()); + // 抛出异常 + throw e; + } + } +} diff --git a/src/main/java/com/rnb/core/security/token/JwtTokenManager.java b/src/main/java/com/rnb/core/security/token/JwtTokenManager.java new file mode 100644 index 0000000..773fee3 --- /dev/null +++ b/src/main/java/com/rnb/core/security/token/JwtTokenManager.java @@ -0,0 +1,230 @@ +package com.rnb.core.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.JSONObject; +import cn.hutool.jwt.JWT; +import cn.hutool.jwt.JWTPayload; +import cn.hutool.jwt.JWTUtil; +import com.rnb.common.constant.JwtClaimConstants; +import com.rnb.common.constant.RedisConstants; +import com.rnb.common.constant.SecurityConstants; +import com.rnb.common.exception.BusinessException; +import com.rnb.common.result.ResultCode; +import com.rnb.config.property.SecurityProperties; +import com.rnb.core.security.model.SysUserDetails; +import com.rnb.core.security.model.AuthenticationToken; +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.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * JWT Token 管理器 + *

+ * 用于生成、解析、校验、刷新 JWT Token + * + * @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); + + 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 + userDetails.setDataScope(payloads.getInt(JwtClaimConstants.DATA_SCOPE)); // 数据权限范围 + + 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) { + JWT jwt = JWTUtil.parseToken(token); + // 检查 Token 是否有效(验签 + 是否过期) + boolean isValid = jwt.setKey(secretKey).validate(0); + + if (isValid) { + // 检查 Token 是否已被加入黑名单(注销、修改密码等场景) + JSONObject payloads = jwt.getPayloads(); + String jti = payloads.getStr(JWTPayload.JWT_ID); + + // 判断是否在黑名单中,如果在,则返回 false 标识Token无效 + if (Boolean.TRUE.equals(redisTemplate.hasKey(StrUtil.format(RedisConstants.Auth.BLACKLIST_TOKEN, jti)))) { + return false; + } + } + return isValid; + } + + @Override + public boolean validateRefreshToken(String refreshToken) { + return this.validateToken(refreshToken); + } + + /** + * 将令牌加入黑名单 + * + * @param token JWT Token + */ + @Override + public void invalidateToken(String token) { + if (token.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) { + token = token.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length()); + } + + JWT jwt = JWTUtil.parseToken(token); + JSONObject payloads = jwt.getPayloads(); + + Integer expirationAt = payloads.getInt(JWTPayload.EXPIRES_AT); + + // 黑名单Token Key + String blacklistTokenKey = StrUtil.format(RedisConstants.Auth.BLACKLIST_TOKEN, payloads.getStr(JWTPayload.JWT_ID)); + + if (expirationAt != null) { + int currentTimeSeconds = Convert.toInt(System.currentTimeMillis() / 1000); + if (expirationAt < currentTimeSeconds) { + // Token已过期,直接返回 + return; + } + // 计算Token剩余时间,将其加入黑名单 + int expirationIn = expirationAt - currentTimeSeconds; + redisTemplate.opsForValue().set(blacklistTokenKey, null, expirationIn, TimeUnit.SECONDS); + } else { + // 永不过期的Token永久加入黑名单 + redisTemplate.opsForValue().set(blacklistTokenKey, null); + } + ; + } + + /** + * 刷新令牌 + * + * @param refreshToken 刷新令牌 + * @return 令牌响应对象 + */ + @Override + public AuthenticationToken refreshToken(String refreshToken) { + + boolean isValid = validateToken(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 过期时间 + * @return JWT Token + */ + private String generateToken(Authentication authentication, int ttl) { + + 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 + payload.put(JwtClaimConstants.DATA_SCOPE, userDetails.getDataScope()); // 数据权限范围 + + // claims 中添加角色信息 + Set roles = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toSet()); + payload.put(JwtClaimConstants.AUTHORITIES, roles); + + Date now = new Date(); + payload.put(JWTPayload.ISSUED_AT, now); + + // 设置过期时间 -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/rnb/core/security/token/RedisTokenManager.java b/src/main/java/com/rnb/core/security/token/RedisTokenManager.java new file mode 100644 index 0000000..c3facab --- /dev/null +++ b/src/main/java/com/rnb/core/security/token/RedisTokenManager.java @@ -0,0 +1,293 @@ +package com.rnb.core.security.token; + +import cn.hutool.core.collection.CollectionUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import com.rnb.common.constant.RedisConstants; +import com.rnb.common.exception.BusinessException; +import com.rnb.common.result.ResultCode; +import com.rnb.config.property.SecurityProperties; +import com.rnb.core.security.model.AuthenticationToken; +import com.rnb.core.security.model.OnlineUser; +import com.rnb.core.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.Set; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * Redis Token 管理器 + *

+ * 用于生成、解析、校验、刷新 JWT Token + * + * @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(); + + // 构建用户在线信息 + OnlineUser onlineUser = new OnlineUser( + user.getUserId(), + user.getUsername(), + user.getDeptId(), + user.getDataScope(), + user.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toSet()) + ); + + // 存储访问令牌、刷新令牌和刷新令牌映射 + storeTokensInRedis(accessToken, refreshToken, onlineUser); + + // 单设备登录控制 + handleSingleDeviceLogin(user.getUserId(), accessToken); + + return AuthenticationToken.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .expiresIn(securityProperties.getSession().getAccessTokenTimeToLive()) + .build(); + } + + /** + * 根据 token 解析用户信息 + * + * @param token JWT Token + * @return 构建的 Authentication 对象 + */ + @Override + public Authentication parseToken(String token) { + OnlineUser onlineUser = (OnlineUser) redisTemplate.opsForValue().get(formatTokenKey(token)); + if (onlineUser == null) return null; + + // 构建用户权限集合 + Set authorities = null; + + Set roles = onlineUser.getRoles(); + if (CollectionUtil.isNotEmpty(roles)) { + authorities = roles.stream() + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toSet()); + } + + // 构建用户详情对象 + SysUserDetails userDetails = buildUserDetails(onlineUser, 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) { + OnlineUser onlineUser = (OnlineUser) redisTemplate.opsForValue().get(StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken)); + if (onlineUser == null) { + throw new BusinessException(ResultCode.REFRESH_TOKEN_INVALID); + } + + String oldAccessToken = (String) redisTemplate.opsForValue().get(StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, onlineUser.getUserId())); + + // 删除旧的访问令牌记录 + if (oldAccessToken != null) { + redisTemplate.delete(formatTokenKey(oldAccessToken)); + } + + // 生成新访问令牌并存储 + String newAccessToken = IdUtil.fastSimpleUUID(); + storeAccessToken(newAccessToken, onlineUser); + + int accessTtl = securityProperties.getSession().getAccessTokenTimeToLive(); + return AuthenticationToken.builder() + .accessToken(newAccessToken) + .refreshToken(refreshToken) + .expiresIn(accessTtl) + .build(); + } + + /** + * 使访问令牌失效 + * + * @param token 访问令牌 + */ + @Override + public void invalidateToken(String token) { + OnlineUser onlineUser = (OnlineUser) redisTemplate.opsForValue().get(formatTokenKey(token)); + if (onlineUser != null) { + Long userId = onlineUser.getUserId(); + // 1. 删除访问令牌相关 + String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userId); + String accessToken = (String) redisTemplate.opsForValue().get(userAccessKey); + if (accessToken != null) { + redisTemplate.delete(formatTokenKey(accessToken)); + redisTemplate.delete(userAccessKey); + } + + // 2. 删除刷新令牌相关 + String userRefreshKey = StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, userId); + String refreshToken = (String) redisTemplate.opsForValue().get(userRefreshKey); + if (refreshToken != null) { + redisTemplate.delete(StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken)); + redisTemplate.delete(userRefreshKey); + } + } + } + + /** + * 将访问令牌和刷新令牌存储至 Redis + * + * @param accessToken 访问令牌 + * @param refreshToken 刷新令牌 + * @param onlineUser 在线用户信息 + */ + private void storeTokensInRedis(String accessToken, String refreshToken, OnlineUser onlineUser) { + // 访问令牌 -> 用户信息 + setRedisValue(formatTokenKey(accessToken), onlineUser, securityProperties.getSession().getAccessTokenTimeToLive()); + + // 刷新令牌 -> 用户信息 + String refreshTokenKey = StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken); + setRedisValue(refreshTokenKey, onlineUser, securityProperties.getSession().getRefreshTokenTimeToLive()); + + // 用户ID -> 刷新令牌 + setRedisValue(StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, onlineUser.getUserId()), + refreshToken, + securityProperties.getSession().getRefreshTokenTimeToLive()); + } + + /** + * 处理单设备登录控制 + * + * @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) { + String oldAccessToken = (String) redisTemplate.opsForValue().get(userAccessKey); + if (oldAccessToken != null) { + redisTemplate.delete(formatTokenKey(oldAccessToken)); + } + } + // 存储访问令牌映射(用户ID -> 访问令牌),用于单设备登录控制删除旧的访问令牌和刷新令牌时删除旧令牌 + setRedisValue(userAccessKey, accessToken, securityProperties.getSession().getAccessTokenTimeToLive()); + } + + /** + * 存储新的访问令牌 + * + * @param newAccessToken 新访问令牌 + * @param onlineUser 在线用户信息 + */ + private void storeAccessToken(String newAccessToken, OnlineUser onlineUser) { + setRedisValue(StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, newAccessToken), onlineUser, securityProperties.getSession().getAccessTokenTimeToLive()); + String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, onlineUser.getUserId()); + setRedisValue(userAccessKey, newAccessToken, securityProperties.getSession().getAccessTokenTimeToLive()); + } + + /** + * 构建用户详情对象 + * + * @param onlineUser 在线用户信息 + * @param authorities 权限集合 + * @return SysUserDetails 用户详情 + */ + private SysUserDetails buildUserDetails(OnlineUser onlineUser, Set authorities) { + SysUserDetails userDetails = new SysUserDetails(); + userDetails.setUserId(onlineUser.getUserId()); + userDetails.setUsername(onlineUser.getUsername()); + userDetails.setDeptId(onlineUser.getDeptId()); + userDetails.setDataScope(onlineUser.getDataScope()); + 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时永不过期 + } + } +} diff --git a/src/main/java/com/rnb/core/security/token/TokenManager.java b/src/main/java/com/rnb/core/security/token/TokenManager.java new file mode 100644 index 0000000..8eadaff --- /dev/null +++ b/src/main/java/com/rnb/core/security/token/TokenManager.java @@ -0,0 +1,68 @@ +package com.rnb.core.security.token; + + +import com.rnb.core.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 JWT Token + */ + default void invalidateToken(String token) { + // 默认实现可以是空的,或者抛出不支持的操作异常 + // throw new UnsupportedOperationException("Not implemented"); + } + + +} diff --git a/src/main/java/com/rnb/core/security/util/SecurityUtils.java b/src/main/java/com/rnb/core/security/util/SecurityUtils.java new file mode 100644 index 0000000..c35934a --- /dev/null +++ b/src/main/java/com/rnb/core/security/util/SecurityUtils.java @@ -0,0 +1,124 @@ +package com.rnb.core.security.util; + +import cn.hutool.core.collection.CollectionUtil; +import cn.hutool.core.util.StrUtil; +import com.rnb.common.constant.SecurityConstants; +import com.rnb.common.constant.SystemConstants; +import com.rnb.core.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.Collection; +import java.util.Optional; +import java.util.Set; +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 Integer + */ + public static Integer getDataScope() { + return getUser().map(SysUserDetails::getDataScope).orElse(null); + } + + + /** + * 获取角色集合 + * + * @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 getTokenFromRequest() { + HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); + return request.getHeader(HttpHeaders.AUTHORIZATION); + } + + +} diff --git a/src/main/java/com/rnb/core/validator/FieldValidator.java b/src/main/java/com/rnb/core/validator/FieldValidator.java new file mode 100644 index 0000000..08ff3e9 --- /dev/null +++ b/src/main/java/com/rnb/core/validator/FieldValidator.java @@ -0,0 +1,33 @@ +package com.rnb.core.validator; + +import com.rnb.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; // 如果字段允许为空,可以返回 true + } + // 检查值是否在允许列表中 + return Arrays.asList(allowedValues).contains(value); + } +} diff --git a/src/main/java/com/rnb/rnb/config/ScheduleConfig.java b/src/main/java/com/rnb/rnb/config/ScheduleConfig.java new file mode 100644 index 0000000..f944873 --- /dev/null +++ b/src/main/java/com/rnb/rnb/config/ScheduleConfig.java @@ -0,0 +1,31 @@ +package com.rnb.rnb.config; + +import cn.hutool.core.convert.Convert; +import com.rnb.common.constant.SystemConstants; +import com.rnb.rnb.service.ApiService; +import com.rnb.system.service.ConfigService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Configurable; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class ScheduleConfig { + @Autowired + private ConfigService configService; + + @Scheduled(cron = "0 0 4 * * ?") +// @Scheduled(cron = "0 * * * * ?") + public void checkClientKey(){ + Object systemConfig = configService.getSystemConfig(SystemConstants.SYSTEM_CONFIG_CLIENT_RESP_KEY); + Integer clientKey = Convert.toInt(systemConfig, 0); + if(clientKey>0) { +// clientKey--; + configService.updateSystemConfig(SystemConstants.SYSTEM_CONFIG_CLIENT_RESP_KEY, --clientKey); + } +// log.info("clientKey=" + clientKey); + } +} diff --git a/src/main/java/com/rnb/rnb/controller/ApiController.java b/src/main/java/com/rnb/rnb/controller/ApiController.java new file mode 100644 index 0000000..162c638 --- /dev/null +++ b/src/main/java/com/rnb/rnb/controller/ApiController.java @@ -0,0 +1,76 @@ +package com.rnb.rnb.controller; + +import com.rnb.rnb.service.ApiService; +import com.rnb.rnb.util.ParamDecoder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.HashMap; + +@RestController +@RequestMapping("/Api") +public class ApiController { + @Autowired + private ParamDecoder decoder; + + @Autowired + private ApiService apiService; + + @GetMapping("/chkServer") + public String chkServer(){ + return "ok"; + } + + @GetMapping("/getUpgradeUrl") + public String getUpgradeUrl(@RequestParam Integer sid){ + return apiService.getUpgradePath(sid); + } + + @PostMapping + public String handleApiRequest(@RequestBody String param) { + HashMap map = decoder.Decode(param); + if(map==null || map.isEmpty() || !map.containsKey("op")) return "err:103"; + + String op = map.get("op"); + switch (op) { + case "getInfo": { + Integer sid = Integer.parseInt(map.get("sid")); + Integer ver = Integer.parseInt(map.get("ver")); + +// System.out.println(String.format("[%s]", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date())) +// + map.toString()); + + return apiService.getInfo(sid, ver); + } + case "userLogin": { + int ver = Integer.parseInt(map.get("ver")); + if(ver<302) + return "err:105"; + + String uname = map.get("un").trim().toLowerCase(); + String web = map.get("web"); + Integer sid = Integer.parseInt(map.get("sid")); + String sDid = map.get("did"); + Long did = sDid!=null ? Long.parseLong(sDid) : null; + + return apiService.userLogin(uname, web, sid, ver, did); + } + case "rbReport": { + Integer uid = Integer.parseInt(map.get("uid")); + Integer amo = Integer.parseInt(map.get("amo")); + + return apiService.rbReport(uid, amo); + } + case "rbLogout": { + Integer uid = Integer.parseInt(map.get("uid")); + Long did = Long.parseLong(map.get("did")); + + return apiService.rbLogout(uid, did); + } + } + + return "err:101"; + } +} diff --git a/src/main/java/com/rnb/rnb/controller/ServiceController.java b/src/main/java/com/rnb/rnb/controller/ServiceController.java new file mode 100644 index 0000000..794aeec --- /dev/null +++ b/src/main/java/com/rnb/rnb/controller/ServiceController.java @@ -0,0 +1,286 @@ +package com.rnb.rnb.controller; + +import cn.hutool.poi.excel.cell.CellSetter; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.rnb.common.annotation.RepeatSubmit; +import com.rnb.common.base.BasePageQuery; +import com.rnb.common.result.PageResult; +import com.rnb.common.result.Result; +import com.rnb.core.security.util.SecurityUtils; +import com.rnb.rnb.model.entity.*; +import com.rnb.rnb.model.query.*; +import com.rnb.rnb.model.vo.*; +import com.rnb.rnb.service.*; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/rnb") +public class ServiceController { + @Autowired + private final AccountService accountService; + @Autowired + private final ExtendService extendService; + @Autowired + private final OplogService oplogService; + @Autowired + private final AmountService amountService; + @Autowired + private final AmountSumService amoSumService; + @Autowired + private final SoftwareService softwareService; + + @GetMapping("/pageAccAdvance") + public PageResult pageAccountAdvance(@ParameterObject AccountPageQuery query) { + IPage page = accountService.pageAdvance(query); + return PageResult.success(page); + } + + @GetMapping("/pageAccAdmin") + public PageResult pageAccountAdmin(@ParameterObject AccountPageQuery query) { + IPage page = accountService.pageAdmin(query); + return PageResult.success(page); + } + + @GetMapping("/pageAccBase") + public PageResult pageAccountBase(@ParameterObject AccountPageQuery query) { + IPage page = accountService.pageBase(query); + return PageResult.success(page); + } + + @PostMapping("/newAccount") + @RepeatSubmit + public Result newAccount() { + Integer accId = accountService.newAccount(); + if(accId==0) + return Result.failed("数据库操作失败"); + + return Result.success(); + } + + @PostMapping("/saveAccount") + @RepeatSubmit + public Result saveAccount(@RequestBody @Valid AccountBaseVo acc) { + Integer accId = accountService.saveOrUpdateAccount(acc); + if(accId==0) + return Result.failed("数据库操作失败"); + else if(accId<0) + return Result.failed("该百家乐账号已存在"); + + if(!"FS".equals(acc.getBacPlatform())) { + String username = SecurityUtils.getUsername(); + int tmNow = (int) (System.currentTimeMillis() / 1000); + Oplog log = new Oplog(); + log.setOpBy(username); + log.setOpTime(tmNow); + log.setOperation(acc.getId() == null ? 1 : 2); //新增/修改 + String desc = "ID" + accId + " AG " + acc.getBacAccount(); + log.setRemark(desc); + oplogService.save(log); + } + + return Result.success(); + } + + @PutMapping(value = "/account/{id}") + public Result setAccountStatus(@PathVariable Integer id,@RequestParam Integer status ) { + boolean result = accountService.setStatus(id, status); + + if(result && status<2){ + Account acc = accountService.getById(id); + if(!"FS".equals(acc.getBacPlatform())) { + String username = SecurityUtils.getUsername(); + int tmNow = (int) (System.currentTimeMillis() / 1000); + Oplog log = new Oplog(); + log.setOpBy(username); + log.setOpTime(tmNow); + log.setOperation(status == 1 ? 3 : 4); //禁用/启用 + String desc = "ID" + acc.getId() + " " + acc.getBacPlatform() + " " + acc.getBacAccount(); + log.setRemark(desc); + oplogService.save(log); + } + } + return Result.judge(result); + } + + @GetMapping("/account/{id}") + public Result getAccount(@PathVariable Integer id) { + Account acc = accountService.getById(id); + return Result.success(acc); + } + + @DeleteMapping("/account/{id}") + public Result delAccount(@PathVariable Integer id) { + Account acc = accountService.getById(id); + boolean ret = accountService.removeById(id); + if(ret && !"FS".equals(acc.getBacPlatform())){ + String username = SecurityUtils.getUsername(); + int tmNow = (int) (System.currentTimeMillis()/1000); + Oplog log = new Oplog(); + log.setOpBy(username); + log.setOpTime(tmNow); + log.setOperation(8); //禁用/启用 + String desc = "ID"+acc.getId() + " " + acc.getBacPlatform() + " " + acc.getBacAccount(); + log.setRemark(desc); + oplogService.save(log); + } + return Result.judge(ret); + } + + @GetMapping("/pageExtend") + public PageResult pageExtend(@ParameterObject ExtendPageQuery query) { + IPage page = extendService.page(query); + return PageResult.success(page); + } + + @PostMapping("/saveExtend") + @RepeatSubmit + public Result saveExtend(@RequestBody @Valid Extend ext) { + Account acc = accountService.getById(ext.getAccId()); + + String unBac = acc.getBacAccount(); + if(unBac.startsWith("164agyl")){ + return Result.failed("彩乐园账号不再延期"); + } + else if(unBac.startsWith("ngjjb")){ + return Result.failed("竞技宝账号不再延期"); + } + + int tmNow = (int) (System.currentTimeMillis()/1000); + boolean bRemark = true, + bFS = "FS".equals(acc.getBacPlatform()); + if(ext.getId()==null){ //新增延期 + int extMon = ext.getExtend(); + int expire = acc.getExpireTime(); + if(expire cancelExtend(@PathVariable Integer id) { + Extend ext = extendService.getById(id); + Account acc = accountService.getById(ext.getAccId()); + int expire = acc.getExpireTime(); + expire -= ext.getExtend() * 2629800; + acc.setExpireTime(expire); + + boolean result = extendService.cancel(id); + if(result){ + accountService.updateById(acc); + + int tmNow = (int) (System.currentTimeMillis()/1000); + String username = SecurityUtils.getUsername(); + Oplog log = new Oplog(); + log.setOpBy(username); + log.setOpTime(tmNow); + log.setOperation(7); //撤回 + String desc = "ID"+acc.getId() + " " + acc.getBacPlatform() + " " + acc.getBacAccount() + + " " + ext.getExtend() + "个月 "; + if(ext.getType()==0) desc += "免费"; + else desc += "收费" + ext.getFee(); + log.setRemark(desc); +// log.setAccId(acc.getId()); +// log.setBacAccount(acc.getBacAccount()); +// log.setBacPlatform(acc.getBacPlatform()); + oplogService.save(log); + } + return Result.judge(result); + } + + @GetMapping("/pageOplog") + public PageResult pageExtend(@ParameterObject OplogPageQuery query) { + IPage page = oplogService.page(query); + return PageResult.success(page); + } + + @GetMapping("/pageAmount") + public PageResult pageAmount(@ParameterObject AmountPageQuery query) { + IPage page = amountService.page(query); + return PageResult.success(page); + } + + @GetMapping("/pageAmountUser") + public PageResult pageAmountUser(@ParameterObject AmountPageQuery query) { + IPage page = amountService.pageUser(query); + return PageResult.success(page); + } + + @GetMapping("/pageAmountSum") + public PageResult pageAmountSum(@ParameterObject AmountSumPageQuery query) { + IPage page = amoSumService.page(query); + return PageResult.success(page); + } + + @GetMapping("/pageSoftware") + public PageResult pageSoftware(@ParameterObject BasePageQuery query) { + IPage page = softwareService.page(query); + return PageResult.success(page); + } + + @PostMapping("/saveSoftware") + @RepeatSubmit + public Result saveSoftware(@RequestBody @Valid Software sw) { + Integer done = softwareService.updateOrSave(sw); + if(done<0) + return Result.failed("软件编号重复"); + + return Result.judge(done==1); + } + + @PutMapping(value = "/software/{id}") + public Result setSoftware(@PathVariable Integer id,@RequestParam Integer status ) { + Software sw = softwareService.getById(id); + if(sw==null) + return Result.failed("无效的软件编号"); + + sw.setDeprecated(status); + softwareService.saveOrUpdate(sw); + return Result.success(); + } +} diff --git a/src/main/java/com/rnb/rnb/mapper/AccountMapper.java b/src/main/java/com/rnb/rnb/mapper/AccountMapper.java new file mode 100644 index 0000000..3f58f0d --- /dev/null +++ b/src/main/java/com/rnb/rnb/mapper/AccountMapper.java @@ -0,0 +1,15 @@ +package com.rnb.rnb.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.rnb.rnb.model.entity.Account; +import com.rnb.rnb.model.query.AccountPageQuery; +import com.rnb.rnb.model.vo.AccountBaseVo; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface AccountMapper extends BaseMapper { + Page pageBase(Page page, AccountPageQuery queryParams); + Page pageAdvance(Page page, AccountPageQuery queryParams); + Page pageAdmin(Page page, AccountPageQuery queryParams); +} diff --git a/src/main/java/com/rnb/rnb/mapper/AmountMapper.java b/src/main/java/com/rnb/rnb/mapper/AmountMapper.java new file mode 100644 index 0000000..1e3bb60 --- /dev/null +++ b/src/main/java/com/rnb/rnb/mapper/AmountMapper.java @@ -0,0 +1,16 @@ +package com.rnb.rnb.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.rnb.rnb.model.entity.Amount; +import com.rnb.rnb.model.query.AmountPageQuery; +import com.rnb.rnb.model.vo.AmountUserVo; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface AmountMapper extends BaseMapper { + IPage page(Page page, AmountPageQuery queryParams); + IPage pageUser(Page page, AmountPageQuery queryParams); +} + diff --git a/src/main/java/com/rnb/rnb/mapper/AmountSumMapper.java b/src/main/java/com/rnb/rnb/mapper/AmountSumMapper.java new file mode 100644 index 0000000..baceee5 --- /dev/null +++ b/src/main/java/com/rnb/rnb/mapper/AmountSumMapper.java @@ -0,0 +1,16 @@ +package com.rnb.rnb.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.rnb.rnb.model.entity.AmountSum; +import com.rnb.rnb.model.query.AmountSumPageQuery; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface AmountSumMapper extends BaseMapper { + IPage pageByDay(Page page, AmountSumPageQuery queryParams); + IPage pageByWeek(Page page, AmountSumPageQuery queryParams); + IPage pageByMonth(Page page, AmountSumPageQuery queryParams); + IPage pageByYear(Page page, AmountSumPageQuery queryParams); +} diff --git a/src/main/java/com/rnb/rnb/mapper/ExtendMapper.java b/src/main/java/com/rnb/rnb/mapper/ExtendMapper.java new file mode 100644 index 0000000..175d8e3 --- /dev/null +++ b/src/main/java/com/rnb/rnb/mapper/ExtendMapper.java @@ -0,0 +1,14 @@ +package com.rnb.rnb.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.rnb.rnb.model.entity.Extend; +import com.rnb.rnb.model.query.ExtendPageQuery; +import com.rnb.rnb.model.vo.AccountBaseVo; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ExtendMapper extends BaseMapper { + IPage page(Page page, ExtendPageQuery queryParams); +} diff --git a/src/main/java/com/rnb/rnb/mapper/OplogMapper.java b/src/main/java/com/rnb/rnb/mapper/OplogMapper.java new file mode 100644 index 0000000..6a465ae --- /dev/null +++ b/src/main/java/com/rnb/rnb/mapper/OplogMapper.java @@ -0,0 +1,13 @@ +package com.rnb.rnb.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.rnb.rnb.model.entity.Oplog; +import com.rnb.rnb.model.query.OplogPageQuery; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface OplogMapper extends BaseMapper { + IPage page(Page page, OplogPageQuery queryParams); +} diff --git a/src/main/java/com/rnb/rnb/mapper/SoftwareMapper.java b/src/main/java/com/rnb/rnb/mapper/SoftwareMapper.java new file mode 100644 index 0000000..c15bc77 --- /dev/null +++ b/src/main/java/com/rnb/rnb/mapper/SoftwareMapper.java @@ -0,0 +1,13 @@ +package com.rnb.rnb.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.rnb.common.base.BasePageQuery; +import com.rnb.rnb.model.entity.Software; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SoftwareMapper extends BaseMapper { +// IPage selectPage(Page page, BasePageQuery queryParams); +} diff --git a/src/main/java/com/rnb/rnb/model/entity/Account.java b/src/main/java/com/rnb/rnb/model/entity/Account.java new file mode 100644 index 0000000..fcaa7bc --- /dev/null +++ b/src/main/java/com/rnb/rnb/model/entity/Account.java @@ -0,0 +1,31 @@ +package com.rnb.rnb.model.entity; + +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import lombok.Data; + +@Data +public class Account { + @TableId(type = IdType.AUTO) + private Integer id; + private String bacAccount; //百家乐账号 + private String bacPlatform; //百家乐平台 + private String linkAccount; //关联账号 + private String linkPlatform;//关联平台 + private Integer expireTime; //有效期 + private Integer addTime; //添加时间 + private String addBy; //添加人 + private Integer editTime; //修改时间 + private String editBy; //修改人 + private Integer logTime; //登录时间 + private Integer logCount; //登录次数 + private Integer appVer; //app版本 + private String remark; //注释 + private Integer amount; //实打流水 + private Integer status; //状态 + + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private Long deviceId; //设备ID +} diff --git a/src/main/java/com/rnb/rnb/model/entity/Amount.java b/src/main/java/com/rnb/rnb/model/entity/Amount.java new file mode 100644 index 0000000..65dea2b --- /dev/null +++ b/src/main/java/com/rnb/rnb/model/entity/Amount.java @@ -0,0 +1,14 @@ +package com.rnb.rnb.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import lombok.Data; + +@Data +public class Amount { + @TableId(type = IdType.AUTO) + private Integer id; + private Integer uid; //用户ID + private Integer amount; //流水 + private Integer reportTime; //上报时间 +} diff --git a/src/main/java/com/rnb/rnb/model/entity/AmountSum.java b/src/main/java/com/rnb/rnb/model/entity/AmountSum.java new file mode 100644 index 0000000..3b255e5 --- /dev/null +++ b/src/main/java/com/rnb/rnb/model/entity/AmountSum.java @@ -0,0 +1,18 @@ +package com.rnb.rnb.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import lombok.Data; + +@Data +public class AmountSum { + @TableId(type = IdType.INPUT) + private Integer sid; + private Integer amountTotal; + private Integer amountHy; + private Integer amountCly; + private Integer amountAbg; + private Integer amountHb; + private Integer amountDb; + private Integer amountOther; +} diff --git a/src/main/java/com/rnb/rnb/model/entity/Extend.java b/src/main/java/com/rnb/rnb/model/entity/Extend.java new file mode 100644 index 0000000..588205c --- /dev/null +++ b/src/main/java/com/rnb/rnb/model/entity/Extend.java @@ -0,0 +1,26 @@ +package com.rnb.rnb.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import lombok.Data; + +@Data +public class Extend { + @TableId(type = IdType.AUTO) + private Integer id; + private Integer accId; //用户ID + private String bacAccount; //百家乐账号 + private String bacPlatform; //百家乐平台 + private String linkAccount; //关联账号 + private String linkPlatform;//关联平台 + private Integer extend; //延期月数 + private Integer type; //状态 0=免费 1=收费 + private Integer fee; //收取费用 + private String reason; //免费原因 + private String addBy; //操作人 + private Integer addTime; //操作时间 + private Integer cancel; //是否撤销 0=否 1=是 + private String remark; //注释 + private String editBy; //修改人 + private Integer editTime; //修改时间 +} diff --git a/src/main/java/com/rnb/rnb/model/entity/Oplog.java b/src/main/java/com/rnb/rnb/model/entity/Oplog.java new file mode 100644 index 0000000..e72510e --- /dev/null +++ b/src/main/java/com/rnb/rnb/model/entity/Oplog.java @@ -0,0 +1,18 @@ +package com.rnb.rnb.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import lombok.Data; + +@Data +public class Oplog { + @TableId(type = IdType.AUTO) + private Integer id; +// private Integer accId; //用户ID +// private String bacAccount; //百家乐账号 +// private String bacPlatform; //百家乐平台 + private Integer operation; //操作 + private Integer opTime; //操作时间 + private String opBy; //操作人 + private String remark; //操作描述 +} diff --git a/src/main/java/com/rnb/rnb/model/entity/Software.java b/src/main/java/com/rnb/rnb/model/entity/Software.java new file mode 100644 index 0000000..c4466b8 --- /dev/null +++ b/src/main/java/com/rnb/rnb/model/entity/Software.java @@ -0,0 +1,17 @@ +package com.rnb.rnb.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import lombok.Data; + +@Data +public class Software { + @TableId(type = IdType.INPUT) + private Integer sid; + + private String sname; + private Integer verLatest; + private Integer verValid; + private String upgradePath; + private Integer deprecated; +} diff --git a/src/main/java/com/rnb/rnb/model/query/AccountPageQuery.java b/src/main/java/com/rnb/rnb/model/query/AccountPageQuery.java new file mode 100644 index 0000000..0576bcb --- /dev/null +++ b/src/main/java/com/rnb/rnb/model/query/AccountPageQuery.java @@ -0,0 +1,15 @@ +package com.rnb.rnb.model.query; + +import com.rnb.common.base.BasePageQuery; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@EqualsAndHashCode(callSuper = true) +@Data +public class AccountPageQuery extends BasePageQuery { + private String bacAccount; + private String linkAccount; + private String linkPlatform; + private String orderBy; + private String order; +} diff --git a/src/main/java/com/rnb/rnb/model/query/AmountPageQuery.java b/src/main/java/com/rnb/rnb/model/query/AmountPageQuery.java new file mode 100644 index 0000000..6afb896 --- /dev/null +++ b/src/main/java/com/rnb/rnb/model/query/AmountPageQuery.java @@ -0,0 +1,18 @@ +package com.rnb.rnb.model.query; + +import com.rnb.common.base.BasePageQuery; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +@EqualsAndHashCode(callSuper = true) +@Data +public class AmountPageQuery extends BasePageQuery { + private Integer uid; + private String bacAccount; + private String bacPlatform; + private String linkPlatform; + private List reportTime; + +} diff --git a/src/main/java/com/rnb/rnb/model/query/AmountSumPageQuery.java b/src/main/java/com/rnb/rnb/model/query/AmountSumPageQuery.java new file mode 100644 index 0000000..44c7586 --- /dev/null +++ b/src/main/java/com/rnb/rnb/model/query/AmountSumPageQuery.java @@ -0,0 +1,14 @@ +package com.rnb.rnb.model.query; + +import com.rnb.common.base.BasePageQuery; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +@EqualsAndHashCode(callSuper = true) +@Data +public class AmountSumPageQuery extends BasePageQuery { + private Integer sumType; //统计模式:0=日/1=周/2=月/3=年 + private List ranges; +} diff --git a/src/main/java/com/rnb/rnb/model/query/ExtendPageQuery.java b/src/main/java/com/rnb/rnb/model/query/ExtendPageQuery.java new file mode 100644 index 0000000..1962cca --- /dev/null +++ b/src/main/java/com/rnb/rnb/model/query/ExtendPageQuery.java @@ -0,0 +1,16 @@ +package com.rnb.rnb.model.query; + +import com.rnb.common.base.BasePageQuery; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +@EqualsAndHashCode(callSuper = true) +@Data +public class ExtendPageQuery extends BasePageQuery { + private Integer type; + private String bacAccount; + private Integer cancel; + private List opTime; +} diff --git a/src/main/java/com/rnb/rnb/model/query/OplogPageQuery.java b/src/main/java/com/rnb/rnb/model/query/OplogPageQuery.java new file mode 100644 index 0000000..478b3c1 --- /dev/null +++ b/src/main/java/com/rnb/rnb/model/query/OplogPageQuery.java @@ -0,0 +1,18 @@ +package com.rnb.rnb.model.query; + +import com.rnb.common.base.BasePageQuery; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +@EqualsAndHashCode(callSuper = true) +@Data +public class OplogPageQuery extends BasePageQuery { + private Integer operation; +// private Integer accId; +// private String bacAccount; + private String opBy; + private String keywords; + private List opTime; +} diff --git a/src/main/java/com/rnb/rnb/model/vo/AccountBaseVo.java b/src/main/java/com/rnb/rnb/model/vo/AccountBaseVo.java new file mode 100644 index 0000000..1ba612c --- /dev/null +++ b/src/main/java/com/rnb/rnb/model/vo/AccountBaseVo.java @@ -0,0 +1,15 @@ +package com.rnb.rnb.model.vo; + +import lombok.Data; + +@Data +public class AccountBaseVo { + private Integer id; + private String bacAccount; + private String bacPlatform; + private String linkAccount; + private String linkPlatform; + private Integer expireTime; + private String remark; + private Integer status; +} diff --git a/src/main/java/com/rnb/rnb/model/vo/AmountUserVo.java b/src/main/java/com/rnb/rnb/model/vo/AmountUserVo.java new file mode 100644 index 0000000..32e3b04 --- /dev/null +++ b/src/main/java/com/rnb/rnb/model/vo/AmountUserVo.java @@ -0,0 +1,13 @@ +package com.rnb.rnb.model.vo; + +import lombok.Data; + +@Data +public class AmountUserVo { + private Integer uid; + private String bacAccount; + private String bacPlatform; + private String linkAccount; + private String linkPlatform; + private Integer amount; +} diff --git a/src/main/java/com/rnb/rnb/service/AccountService.java b/src/main/java/com/rnb/rnb/service/AccountService.java new file mode 100644 index 0000000..a283c0b --- /dev/null +++ b/src/main/java/com/rnb/rnb/service/AccountService.java @@ -0,0 +1,120 @@ +package com.rnb.rnb.service; + +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.rnb.core.security.util.SecurityUtils; +import com.rnb.rnb.mapper.AccountMapper; +import com.rnb.rnb.model.entity.Account; +import com.rnb.rnb.model.query.AccountPageQuery; +import com.rnb.rnb.model.vo.AccountBaseVo; +import com.rnb.rnb.util.ActCodeGen; +import jakarta.validation.Valid; +import org.springframework.stereotype.Service; + +import java.util.Objects; + +@Service +public class AccountService extends ServiceImpl { + public IPage pageBase(AccountPageQuery queryParams) { + Page page = new Page<>(queryParams.getPageNum(), + queryParams.getPageSize()); + return baseMapper.pageBase(page, unifyQuery(queryParams)); + } + + public IPage pageAdvance(AccountPageQuery queryParams) { + Page page = new Page<>(queryParams.getPageNum(), + queryParams.getPageSize()); + return baseMapper.pageAdvance(page, unifyQuery(queryParams)); + } + + public IPage pageAdmin(AccountPageQuery queryParams) { + Page page = new Page<>(queryParams.getPageNum(), + queryParams.getPageSize()); + return baseMapper.pageAdmin(page, unifyQuery(queryParams)); + } + + private AccountPageQuery unifyQuery(AccountPageQuery queryParams){ + String order = queryParams.getOrder(); + if("ascending".equals(order)) + queryParams.setOrder("ASC"); + else if("descending".equals(order)) + queryParams.setOrder("DESC"); + + return queryParams; + } + + public Integer newAccount(){ + int tmNow = (int) (System.currentTimeMillis()/1000); + Account account = new Account(); + account.setAddTime(tmNow); + account.setExpireTime(tmNow + 86400); //试用1天 + account.setBacAccount(ActCodeGen.GenerateCode()); + account.setBacPlatform("FS"); + + if(save(account)) + return account.getId(); + + return 0; + } + + public Integer saveOrUpdateAccount(@Valid AccountBaseVo acc) { + String bacAc = acc.getBacAccount(); + String bacPl = acc.getBacPlatform(); + Account account = getAccountByBac(bacAc, bacPl); + if(account!=null && !account.getId().equals(acc.getId())) //百家乐账号已存在 + return -1; + + String username = SecurityUtils.getUsername(); + int tmNow = (int) (System.currentTimeMillis()/1000); + + if(account!=null) { + account.setEditBy(username); + account.setEditTime(tmNow); + } + else { + account = new Account(); + account.setAddBy(username); + account.setAddTime(tmNow); + account.setExpireTime(tmNow); + account.setBacAccount(bacAc); + account.setBacPlatform(bacPl); + } + + account.setRemark(acc.getRemark()); + String linkPlatform = acc.getLinkPlatform(); + if(linkPlatform!=null && !linkPlatform.isEmpty()){ + account.setLinkPlatform(linkPlatform); + account.setLinkAccount(acc.getLinkAccount()); + } + + if(saveOrUpdate(account)) + return account.getId(); + + return 0; + } + + public boolean setStatus(Integer id, Integer status) { + String username = SecurityUtils.getUsername(); + int tmNow = (int) (System.currentTimeMillis()/1000); + + Account account = getById(id); + if(account==null) + return false; + + account.setEditBy(username); + account.setEditTime(tmNow); + account.setStatus(status); + account.setExpireTime(status==0 ? tmNow : 0); + + return updateById(account); + } + + public Account getAccountByBac(String bacAccount, String bacPlatform){ + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(Account::getBacAccount, bacAccount) + .eq(Account::getBacPlatform, bacPlatform); + return getOne(queryWrapper); + } +} diff --git a/src/main/java/com/rnb/rnb/service/AmountService.java b/src/main/java/com/rnb/rnb/service/AmountService.java new file mode 100644 index 0000000..81e4d24 --- /dev/null +++ b/src/main/java/com/rnb/rnb/service/AmountService.java @@ -0,0 +1,31 @@ +package com.rnb.rnb.service; + +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.rnb.rnb.mapper.AmountMapper; +import com.rnb.rnb.model.entity.Amount; +import com.rnb.rnb.model.query.AmountPageQuery; +import com.rnb.rnb.model.vo.AmountUserVo; +import org.springframework.stereotype.Service; + +@Service +public class AmountService extends ServiceImpl { + public IPage page(AmountPageQuery queryParams) { + // 参数构建 + int pageNum = queryParams.getPageNum(); + int pageSize = queryParams.getPageSize(); + Page page = new Page<>(pageNum, pageSize); + + return baseMapper.page(page, queryParams); + } + + public IPage pageUser(AmountPageQuery queryParams) { + // 参数构建 + int pageNum = queryParams.getPageNum(); + int pageSize = queryParams.getPageSize(); + Page page = new Page<>(pageNum, pageSize); + + return baseMapper.pageUser(page, queryParams); + } +} diff --git a/src/main/java/com/rnb/rnb/service/AmountSumService.java b/src/main/java/com/rnb/rnb/service/AmountSumService.java new file mode 100644 index 0000000..abee3f3 --- /dev/null +++ b/src/main/java/com/rnb/rnb/service/AmountSumService.java @@ -0,0 +1,26 @@ +package com.rnb.rnb.service; + +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.rnb.rnb.mapper.AmountSumMapper; +import com.rnb.rnb.model.entity.AmountSum; +import com.rnb.rnb.model.query.AmountSumPageQuery; +import org.springframework.stereotype.Service; + +@Service +public class AmountSumService extends ServiceImpl { + public IPage page(AmountSumPageQuery queryParams) { + // 参数构建 + int pageNum = queryParams.getPageNum(); + int pageSize = queryParams.getPageSize(); + Page page = new Page<>(pageNum, pageSize); + + return switch (queryParams.getSumType()) { + case 1 -> baseMapper.pageByWeek(page, queryParams); + case 2 -> baseMapper.pageByMonth(page, queryParams); + case 3 -> baseMapper.pageByYear(page, queryParams); + default -> baseMapper.pageByDay(page, queryParams); + }; + } +} diff --git a/src/main/java/com/rnb/rnb/service/ApiService.java b/src/main/java/com/rnb/rnb/service/ApiService.java new file mode 100644 index 0000000..44abc23 --- /dev/null +++ b/src/main/java/com/rnb/rnb/service/ApiService.java @@ -0,0 +1,196 @@ +package com.rnb.rnb.service; + +import cn.hutool.core.convert.Convert; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.rnb.common.constant.SystemConstants; +import com.rnb.rnb.model.entity.Account; +import com.rnb.rnb.model.entity.Amount; +import com.rnb.rnb.model.entity.AmountSum; +import com.rnb.rnb.model.entity.Software; +import com.rnb.system.service.ConfigService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Objects; + +@Service +public class ApiService { + @Autowired + private AccountService accountService; + @Autowired + private ConfigService configService; + @Autowired + private AmountService amountService; + @Autowired + private AmountSumService amoSumService; + @Autowired + private SoftwareService softwareService; + + public String getUpgradePath(int sid){ + LambdaQueryWrapper swWrapper = new LambdaQueryWrapper<>(); + swWrapper.eq(Software::getSid,sid) + .eq(Software::getDeprecated,0); + Software software = softwareService.getOne(swWrapper); + if(software == null) return "err:101"; + + return "url:" + software.getUpgradePath(); + } + + public String getInfo(Integer sid, Integer ver) { + LambdaQueryWrapper swWrapper = new LambdaQueryWrapper<>(); + swWrapper.eq(Software::getSid,sid) + .eq(Software::getDeprecated,0); + Software software = softwareService.getOne(swWrapper); + if(software == null) return "err:101"; + + return "flg:" + (software.getVerValid() > ver ? "0" : "1") + "|ver:" + software.getVerLatest(); + } + + public String userLogin(String uname, String web, Integer sid, Integer ver, Long did) { + if(sid!=23 && sid!=24) return "err:104"; + + if(uname.equals("0")){ + configService.updateSystemConfig(SystemConstants.SYSTEM_CONFIG_CLIENT_RESP_KEY, SystemConstants.DEFAULT_CLIENT_KEY); + return "ok"; + } + else if(uname.equals("1")){ //关闭软件 + configService.updateSystemConfig(SystemConstants.SYSTEM_CONFIG_CLIENT_RESP_KEY, 0); + return "closed"; + } + else{ + Object systemConfig = configService.getSystemConfig(SystemConstants.SYSTEM_CONFIG_CLIENT_RESP_KEY); + if(Convert.toInt(systemConfig, 0)<=0) + return "err:105"; + } + + Integer now = (int) (System.currentTimeMillis()/1000); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(Account::getBacAccount, uname); + if(web!=null && !web.isEmpty()){ + wrapper.eq(Account::getBacPlatform, web); + } + wrapper.last("limit 1"); + Account account = accountService.getOne(wrapper); + + if(account == null){ + if("FS".equals(web)) + return "err:102"; //无效授权码 + + account = new Account(); + account.setBacAccount(uname); + account.setBacPlatform(web); + account.setAddTime(now); + account.setExpireTime(now + 10800);//("ABG".equals(web) ? 86400 : 10800)); + account.setLogCount(1); + account.setStatus(0); + } + else { + if("FS".equals(web)){ + Long accDid = account.getDeviceId(); + if(accDid!=null && accDid!=0 && !Objects.equals(did, account.getDeviceId()) //登录设备不一致 + && now - account.getLogTime() < 86400){ //登录时间在一天之内 + return "err:106"; + } + + account.setDeviceId(account.getExpireTime() > now ? did : null); + } + account.setLogCount(account.getLogCount() + 1); + } + account.setLogTime(now); + account.setAppVer(ver); + + if(account.getId()==null) + accountService.save(account); + else + accountService.updateById(account); + + int exp = 0; + if(account.getStatus()==0) + exp = account.getExpireTime(); + + return "uid:" + account.getId() + "|exp:" + exp; + } + + public String rbReport(Integer uid, Integer amo) { + LocalDateTime dt = LocalDateTime.now(); + Integer now = (int) (dt.toEpochSecond(ZoneOffset.of("+8"))); + Amount amount = new Amount(); + amount.setUid(uid); + amount.setAmount(amo); + amount.setReportTime(now); + amountService.save(amount); + + String lpt = "", bpt = ""; + Account account = accountService.getById(uid); + if(account!=null){ + Integer oldAmo = account.getAmount(); + if(oldAmo==null) oldAmo = 0; + account.setAmount(oldAmo + amo); + accountService.updateById(account); + + lpt = account.getLinkPlatform(); + bpt = account.getBacPlatform(); + } + + Integer sid = dt.getYear() * 10000 + dt.getMonthValue() * 100 + dt.getDayOfMonth(); + AmountSum as = amoSumService.getById(sid); + if(as==null){ + as = new AmountSum(); + as.setSid(sid); + as.setAmountTotal(amo); + } + else { + as.setAmountTotal(as.getAmountTotal()+amo); + } + + if( "恒耀".equals(lpt)){ + Integer oldAmo = as.getAmountHy(); + if(oldAmo==null) oldAmo = 0; + as.setAmountHy(oldAmo + amo); + } + else if("浩博".equals(lpt)){ + Integer oldAmo = as.getAmountHb(); + if(oldAmo==null) oldAmo = 0; + as.setAmountHb(oldAmo + amo); + } + else if("彩乐园".equals(lpt)){ + Integer oldAmo = as.getAmountCly(); + if(oldAmo==null) oldAmo = 0; + as.setAmountCly(oldAmo + amo); + } + else if("ABG".equals(bpt)){ + Integer oldAmo = as.getAmountAbg(); + if(oldAmo==null) oldAmo = 0; + as.setAmountAbg(oldAmo + amo); + } + else if("DB".equals(bpt)){ + Integer oldAmo = as.getAmountAbg(); + if(oldAmo==null) oldAmo = 0; + as.setAmountDb(oldAmo + amo); + } + else{ + Integer oldAmo = as.getAmountOther(); + if(oldAmo==null) oldAmo = 0; + as.setAmountOther(oldAmo + amo); + } + + amoSumService.saveOrUpdate(as); + + return "ok"; + } + + public String rbLogout(Integer uid, Long did) { + Account account = accountService.getById(uid); + if (account!=null && Objects.equals(did, account.getDeviceId())) { + account.setDeviceId(null); + if(!accountService.updateById(account)) + System.out.println("更新设备ID失败"); + } + + return "ok"; + } +} diff --git a/src/main/java/com/rnb/rnb/service/ExtendService.java b/src/main/java/com/rnb/rnb/service/ExtendService.java new file mode 100644 index 0000000..17f5e81 --- /dev/null +++ b/src/main/java/com/rnb/rnb/service/ExtendService.java @@ -0,0 +1,74 @@ +package com.rnb.rnb.service; + +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.rnb.core.security.util.SecurityUtils; +import com.rnb.rnb.mapper.AccountMapper; +import com.rnb.rnb.mapper.ExtendMapper; +import com.rnb.rnb.model.entity.Account; +import com.rnb.rnb.model.entity.Extend; +import com.rnb.rnb.model.query.AccountPageQuery; +import com.rnb.rnb.model.query.ExtendPageQuery; +import com.rnb.rnb.model.vo.AccountBaseVo; +import jakarta.validation.Valid; +import org.springframework.stereotype.Service; + +@Service +public class ExtendService extends ServiceImpl { + public IPage page(ExtendPageQuery queryParams) { + // 参数构建 + int pageNum = queryParams.getPageNum(); + int pageSize = queryParams.getPageSize(); + Page page = new Page<>(pageNum, pageSize); + + return baseMapper.page(page, queryParams); + } + + public boolean saveOrUpdateExtend(@Valid Extend ext) { + String username = SecurityUtils.getUsername(); + int tmNow = (int) (System.currentTimeMillis()/1000); + + Extend extend = null; + Integer id = ext.getId(); + if(id!=null) { + extend = getById(id); + extend.setEditBy(username); + extend.setEditTime(tmNow); + } + else { + extend = new Extend(); + extend.setAddBy(username); + extend.setAddTime(tmNow); + extend.setAccId(ext.getAccId()); + extend.setBacPlatform(ext.getBacPlatform()); + extend.setBacAccount(ext.getBacAccount()); + extend.setLinkPlatform(ext.getLinkPlatform()); + extend.setLinkAccount(ext.getLinkAccount()); + extend.setExtend(ext.getExtend()); + extend.setType(ext.getType()); + extend.setFee(ext.getFee()); + extend.setReason(ext.getReason()); + } + + extend.setRemark(ext.getRemark()); + + return saveOrUpdate(extend); + } + + public boolean cancel(Integer id) { + String username = SecurityUtils.getUsername(); + int tmNow = (int) (System.currentTimeMillis()/1000); + + Extend extend = getById(id); + if(extend==null) + return false; + + extend.setEditBy(username); + extend.setEditTime(tmNow); + extend.setCancel(1); + + return updateById(extend); + } +} diff --git a/src/main/java/com/rnb/rnb/service/OplogService.java b/src/main/java/com/rnb/rnb/service/OplogService.java new file mode 100644 index 0000000..5e53c96 --- /dev/null +++ b/src/main/java/com/rnb/rnb/service/OplogService.java @@ -0,0 +1,21 @@ +package com.rnb.rnb.service; + +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.rnb.rnb.mapper.OplogMapper; +import com.rnb.rnb.model.entity.Oplog; +import com.rnb.rnb.model.query.OplogPageQuery; +import org.springframework.stereotype.Service; + +@Service +public class OplogService extends ServiceImpl { + public IPage page(OplogPageQuery queryParams) { + // 参数构建 + int pageNum = queryParams.getPageNum(); + int pageSize = queryParams.getPageSize(); + Page page = new Page<>(pageNum, pageSize); + + return baseMapper.page(page, queryParams); + } +} diff --git a/src/main/java/com/rnb/rnb/service/SoftwareService.java b/src/main/java/com/rnb/rnb/service/SoftwareService.java new file mode 100644 index 0000000..d48de19 --- /dev/null +++ b/src/main/java/com/rnb/rnb/service/SoftwareService.java @@ -0,0 +1,37 @@ +package com.rnb.rnb.service; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +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.rnb.common.base.BasePageQuery; +import com.rnb.rnb.mapper.SoftwareMapper; +import com.rnb.rnb.model.entity.Account; +import com.rnb.rnb.model.entity.Oplog; +import com.rnb.rnb.model.entity.Software; +import com.rnb.rnb.model.query.OplogPageQuery; +import com.rnb.rnb.model.vo.AccountBaseVo; +import jakarta.validation.Valid; +import org.springframework.stereotype.Service; + +@Service +public class SoftwareService extends ServiceImpl, Software> { + public IPage page(BasePageQuery queryParams) { + // 参数构建 + int pageNum = queryParams.getPageNum(); + int pageSize = queryParams.getPageSize(); + Page page = new Page<>(pageNum, pageSize); + + return baseMapper.selectPage(page, null); + } + + public Integer updateOrSave(@Valid Software sw) { + if(sw.getDeprecated()==null) { //新软件 + if (getById(sw.getSid()) != null) + return -1; + } + + return saveOrUpdate(sw) ? 1 : 0; + } +} diff --git a/src/main/java/com/rnb/rnb/util/ActCodeGen.java b/src/main/java/com/rnb/rnb/util/ActCodeGen.java new file mode 100644 index 0000000..59392fe --- /dev/null +++ b/src/main/java/com/rnb/rnb/util/ActCodeGen.java @@ -0,0 +1,64 @@ +package com.rnb.rnb.util; + +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Component +public class ActCodeGen { + + public static String GenerateCode() { + byte[] code = new byte[4]; + + LocalDateTime dt = LocalDateTime.now(); + int year = dt.getYear() - 2026; //0-5 bit + int rand = (int) (Math.random() * 64); + int month = dt.getMonthValue(); //4 bit + int day = dt.getDayOfMonth(); //5 bit + int hour = dt.getHour(); //5 bit + int minute = dt.getMinute(); //6 bit + int second = dt.getSecond(); //6 bit + + code[0] = (byte) (rand << 2); + code[0] |= (byte) (month >> 2); + code[1] = (byte) (month << 6); + code[1] |= (byte) (day << 1); + code[1] |= (byte) (hour >> 4); + code[2] = (byte) (hour << 4); + code[2] |= (byte) (minute >> 2); + code[3] = (byte) (minute << 6); + code[3] |= (byte) (second); + + System.out.println(year + " " + rand + " " + month + " " + day + " " + hour + " " + minute + " " + second); + System.out.println(Integer.toBinaryString(year) + " " + + Integer.toBinaryString(rand) + " " + + Integer.toBinaryString(month) + " " + + Integer.toBinaryString(day) + " " + + Integer.toBinaryString(hour) + " " + + Integer.toBinaryString(minute) + " " + + Integer.toBinaryString(second)); + System.out.println(toBinary(code[0] & 0xff) + " " + + toBinary(code[1] & 0xff) + " " + + toBinary(code[2] & 0xff) + " " + + toBinary(code[3] & 0xff)); + + StringBuilder codeStr = new StringBuilder(); + for (int i = 0; i < 4; i++) { + byte b = code[i]; + codeStr.append(toHex(b & 0xff).toUpperCase()); + } + return Integer.toHexString(year).toUpperCase() + codeStr; + } + + private static String toBinary(int num) { + int value = 256 | num; + String bs = Integer.toBinaryString(value); + return bs.substring(1); + } + + private static String toHex(int num) { + int value = 256 | num; + String bs = Integer.toHexString(value); + return bs.substring(1); + } +} diff --git a/src/main/java/com/rnb/rnb/util/ParamDecoder.java b/src/main/java/com/rnb/rnb/util/ParamDecoder.java new file mode 100644 index 0000000..3894c07 --- /dev/null +++ b/src/main/java/com/rnb/rnb/util/ParamDecoder.java @@ -0,0 +1,101 @@ +package com.rnb.rnb.util; + +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; + +@Component +public class ParamDecoder { + private static final int SHIFT_NUM = 3; + private static final int MAX_KEY_NUM = 32; + private static final int MAX_STR_LEN = 256; + private static final byte[] base64_decode_map = { + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, 62, -1, -1, -1, 63, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, + -1, 0, -1, -1, -1, 1, 0, 3, 2, 5, 4, 6, 7, 8, 9, 10, 11, 12, 13, 14, + 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, -1, -1, 27, 26, 29, + 28, 31, 30, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, + 49, 50, 51, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}; + + private byte[] Base64Decode(byte[] src){ + byte[] dst = new byte[src.length]; + for (int i=0, j=0; i > 4); + dst[j++] = (byte) ((base64_decode_map[src[i + 1]] & 0xff) << 4 | (base64_decode_map[src[i + 2]] & 0xff) >> 2); + dst[j++] = (byte) ((base64_decode_map[src[i + 2]] & 0xff) << 6 | base64_decode_map[src[i + 3]]); + } + return dst; + } + + private void ShiftCopy( byte[] buf, boolean bReverse ) + { + int shift = bReverse ? (8-SHIFT_NUM) : SHIFT_NUM; + byte mR = (byte) ((1 << (8-shift)) - 1), mL = (byte) (0xFF - mR); + + for( int i=0; i> (8-shift)) | ((ch & mR) << shift) ); + } + } + + public HashMap Decode(String inParam){ + HashMap params = null; + + byte[] src = inParam.getBytes(); + + if(src.length == 0) return null; + + byte[] dst = Base64Decode( src ); + + if(dst.length == 0) return null; + + ShiftCopy(dst,true); + + int vc1 = 0, vc2 = 0; + for(int i=0; i<4; i++){ + vc1 += (dst[i] & 0xff) << (i*8); + } + for(int i=4; i0 && count<=MAX_KEY_NUM ){ + params = new HashMap<>(); + + for( int i=0; i getCaptcha() { + CaptchaInfo captcha = authService.getCaptcha(); + return Result.success(captcha); + } + + @Operation(summary = "账号密码登录") + @PostMapping("/login") + @Log(value = "登录", module = LogModuleEnum.LOGIN) + public Result login( + @Parameter(description = "用户名", example = "admin") @RequestParam String username, + @Parameter(description = "密码", example = "123456") @RequestParam String password + ) { + AuthenticationToken authenticationToken = authService.login(username, password); + return Result.success(authenticationToken); + } + + @Operation(summary = "注销登录") + @DeleteMapping("/logout") + @Log(value = "注销", module = LogModuleEnum.LOGIN) + 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); + } + + @Operation(summary = "微信授权登录") + @PostMapping("/login/wechat") + @Log(value = "微信登录", module = LogModuleEnum.LOGIN) + public Result loginByWechat( + @Parameter(description = "微信授权码", example = "code") @RequestParam String code + ) { + AuthenticationToken loginResult = authService.loginByWechat(code); + return Result.success(loginResult); + } + + @Operation(summary = "发送登录短信验证码") + @PostMapping("/login/sms/code") + public Result sendLoginVerifyCode( + @Parameter(description = "手机号", example = "18812345678") @RequestParam String mobile + ) { + authService.sendSmsLoginCode(mobile); + return Result.success(); + } + + @Operation(summary = "短信验证码登录") + @PostMapping("/login/sms") + @Log(value = "短信验证码登录", module = LogModuleEnum.LOGIN) + public Result loginBySms( + @Parameter(description = "手机号", example = "18812345678") @RequestParam String mobile, + @Parameter(description = "验证码", example = "1234") @RequestParam String code + ) { + AuthenticationToken loginResult = authService.loginBySms(mobile, code); + return Result.success(loginResult); + } +} diff --git a/src/main/java/com/rnb/shared/auth/enums/CaptchaTypeEnum.java b/src/main/java/com/rnb/shared/auth/enums/CaptchaTypeEnum.java new file mode 100644 index 0000000..dbc9179 --- /dev/null +++ b/src/main/java/com/rnb/shared/auth/enums/CaptchaTypeEnum.java @@ -0,0 +1,27 @@ +package com.rnb.shared.auth.enums; + +/** + * EasyCaptcha 验证码类型枚举 + * + * @author haoxr + * @since 2.5.1 + */ +public enum CaptchaTypeEnum { + + /** + * 圆圈干扰验证码 + */ + CIRCLE, + /** + * GIF验证码 + */ + GIF, + /** + * 干扰线验证码 + */ + LINE, + /** + * 扭曲干扰验证码 + */ + SHEAR +} diff --git a/src/main/java/com/rnb/shared/auth/model/CaptchaInfo.java b/src/main/java/com/rnb/shared/auth/model/CaptchaInfo.java new file mode 100644 index 0000000..bb5320e --- /dev/null +++ b/src/main/java/com/rnb/shared/auth/model/CaptchaInfo.java @@ -0,0 +1,24 @@ +package com.rnb.shared.auth.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +/** + * 验证码信息 + * + * @author Ray。Hao + * @since 2023/03/24 + */ +@Schema(description = "验证码信息") +@Data +@Builder +public class CaptchaInfo { + + @Schema(description = "验证码缓存 Key") + private String captchaKey; + + @Schema(description = "验证码图片Base64字符串") + private String captchaBase64; + +} diff --git a/src/main/java/com/rnb/shared/auth/service/AuthService.java b/src/main/java/com/rnb/shared/auth/service/AuthService.java new file mode 100644 index 0000000..9f57d5d --- /dev/null +++ b/src/main/java/com/rnb/shared/auth/service/AuthService.java @@ -0,0 +1,66 @@ +package com.rnb.shared.auth.service; + +import com.rnb.shared.auth.model.CaptchaInfo; +import com.rnb.core.security.model.AuthenticationToken; + +/** + * 认证服务接口 + * + * @author Ray.Hao + * @since 2.4.0 + */ +public interface AuthService { + + /** + * 登录 + * + * @param username 用户名 + * @param password 密码 + * @return 登录结果 + */ + AuthenticationToken login(String username, String password); + + /** + * 登出 + */ + void logout(); + + /** + * 获取验证码 + * + * @return 验证码 + */ + CaptchaInfo getCaptcha(); + + /** + * 刷新令牌 + * + * @param refreshToken 刷新令牌 + * @return 登录结果 + */ + AuthenticationToken refreshToken(String refreshToken); + + /** + * 微信小程序登录 + * + * @param code 微信登录code + * @return 登录结果 + */ + AuthenticationToken loginByWechat(String code); + + /** + * 发送短信验证码 + * + * @param mobile 手机号 + */ + void sendSmsLoginCode(String mobile); + + /** + * 短信验证码登录 + * + * @param mobile 手机号 + * @param code 验证码 + * @return 登录结果 + */ + AuthenticationToken loginBySms(String mobile, String code); +} diff --git a/src/main/java/com/rnb/shared/auth/service/impl/AuthServiceImpl.java b/src/main/java/com/rnb/shared/auth/service/impl/AuthServiceImpl.java new file mode 100644 index 0000000..97557ea --- /dev/null +++ b/src/main/java/com/rnb/shared/auth/service/impl/AuthServiceImpl.java @@ -0,0 +1,231 @@ +package com.rnb.shared.auth.service.impl; + +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.rnb.common.constant.RedisConstants; +import com.rnb.common.constant.SecurityConstants; +import com.rnb.common.exception.BusinessException; +import com.rnb.common.result.ResultCode; +import com.rnb.config.property.CaptchaProperties; +import com.rnb.core.security.extension.sms.SmsAuthenticationToken; +import com.rnb.core.security.extension.wechat.WechatAuthenticationToken; +import com.rnb.core.security.util.SecurityUtils; +import com.rnb.shared.auth.enums.CaptchaTypeEnum; +import com.rnb.core.security.model.AuthenticationToken; +import com.rnb.shared.auth.model.CaptchaInfo; +import com.rnb.shared.auth.service.AuthService; +import com.rnb.core.security.token.TokenManager; +import com.rnb.shared.sms.enums.SmsTypeEnum; +import com.rnb.shared.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.awt.*; +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 Font captchaFont; + private final CaptchaProperties captchaProperties; + private final CodeGenerator codeGenerator; + + private final SmsService smsService; + private final RedisTemplate redisTemplate; + + /** + * 用户名密码登录 + * + * @param username 用户名 + * @param password 密码 + * @return 访问令牌 + */ + @Override + public AuthenticationToken login(String username, String password) { + // 1. 创建用于密码认证的令牌(未认证) + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(username.trim(), password); + + // 2. 执行认证(认证中) + Authentication authentication = authenticationManager.authenticate(authenticationToken); + + // 3. 认证成功后生成 JWT 令牌,并存入 Security 上下文,供登录日志 AOP 使用(已认证) + AuthenticationToken authenticationTokenResponse = + tokenManager.generateToken(authentication); + SecurityContextHolder.getContext().setAuthentication(authentication); + return authenticationTokenResponse; + } + + /** + * 微信一键授权登录 + * + * @param code 微信登录code + * @return 访问令牌 + */ + @Override + public AuthenticationToken loginByWechat(String code) { + // 1. 创建用户微信认证的令牌(未认证) + WechatAuthenticationToken wechatAuthenticationToken = new WechatAuthenticationToken(code); + + // 2. 执行认证(认证中) + Authentication authentication = authenticationManager.authenticate(wechatAuthenticationToken); + + // 3. 认证成功后生成 JWT 令牌,并存入 Security 上下文,供登录日志 AOP 使用(已认证) + AuthenticationToken authenticationToken = tokenManager.generateToken(authentication); + SecurityContextHolder.getContext().setAuthentication(authentication); + + return authenticationToken; + } + + /** + * 发送登录短信验证码 + * + * @param mobile 手机号 + */ + @Override + public void sendSmsLoginCode(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.getTokenFromRequest(); + if (StrUtil.isNotBlank(token) && token.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX )) { + token = token.substring(SecurityConstants.BEARER_TOKEN_PREFIX .length()); + // 将JWT令牌加入黑名单 + tokenManager.invalidateToken(token); + // 清除Security上下文 + SecurityContextHolder.clearContext(); + } + } + + /** + * 获取验证码 + * + * @return 验证码 + */ + @Override + public CaptchaInfo getCaptcha() { + + 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(); + + // 验证码文本缓存至Redis,用于登录校验 + String captchaKey = IdUtil.fastSimpleUUID(); + redisTemplate.opsForValue().set( + StrUtil.format(RedisConstants.Captcha.IMAGE_CODE, captchaKey), + captchaCode, + captchaProperties.getExpireSeconds(), + TimeUnit.SECONDS + ); + + return CaptchaInfo.builder() + .captchaKey(captchaKey) + .captchaBase64(imageBase64Data) + .build(); + } + + /** + * 刷新token + * + * @param refreshToken 刷新令牌 + * @return 新的访问令牌 + */ + @Override + public AuthenticationToken refreshToken(String refreshToken) { + // 验证刷新令牌 + boolean isValidate = tokenManager.validateRefreshToken(refreshToken); + + if (!isValidate) { + throw new BusinessException(ResultCode.REFRESH_TOKEN_INVALID); + } + // 刷新令牌有效,生成新的访问令牌 + return tokenManager.refreshToken(refreshToken); + } + + +} diff --git a/src/main/java/com/rnb/shared/codegen/controller/CodegenController.java b/src/main/java/com/rnb/shared/codegen/controller/CodegenController.java new file mode 100644 index 0000000..ddff005 --- /dev/null +++ b/src/main/java/com/rnb/shared/codegen/controller/CodegenController.java @@ -0,0 +1,109 @@ +package com.rnb.shared.codegen.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.rnb.common.result.PageResult; +import com.rnb.common.result.Result; +import com.rnb.config.property.CodegenProperties; +import com.rnb.common.enums.LogModuleEnum; +import com.rnb.shared.codegen.service.CodegenService; +import com.rnb.shared.codegen.model.form.GenConfigForm; +import com.rnb.shared.codegen.model.query.TablePageQuery; +import com.rnb.shared.codegen.model.vo.CodegenPreviewVO; +import com.rnb.shared.codegen.model.vo.TablePageVO; +import com.rnb.common.annotation.Log; +import com.rnb.shared.codegen.service.GenConfigService; +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 GenConfigService genConfigService; + private final CodegenProperties codegenProperties; + + @Operation(summary = "获取数据表分页列表") + @GetMapping("/table/page") + @Log(value = "代码生成分页列表", module = LogModuleEnum.OTHER) + public PageResult getTablePage( + TablePageQuery queryParams + ) { + Page result = codegenService.getTablePage(queryParams); + return PageResult.success(result); + } + + @Operation(summary = "获取代码生成配置") + @GetMapping("/{tableName}/config") + public Result getGenConfigFormData( + @Parameter(description = "表名", example = "sys_user") @PathVariable String tableName + ) { + GenConfigForm formData = genConfigService.getGenConfigFormData(tableName); + return Result.success(formData); + } + + @Operation(summary = "保存代码生成配置") + @PostMapping("/{tableName}/config") + @Log(value = "生成代码", module = LogModuleEnum.OTHER) + public Result saveGenConfig(@RequestBody GenConfigForm formData) { + genConfigService.saveGenConfig(formData); + return Result.success(); + } + + @Operation(summary = "删除代码生成配置") + @DeleteMapping("/{tableName}/config") + public Result deleteGenConfig( + @Parameter(description = "表名", example = "sys_user") @PathVariable String tableName + ) { + genConfigService.deleteGenConfig(tableName); + return Result.success(); + } + + @Operation(summary = "获取预览生成代码") + @GetMapping("/{tableName}/preview") + @Log(value = "预览生成代码", module = LogModuleEnum.OTHER) + public Result> getTablePreviewData(@PathVariable String tableName) { + List list = codegenService.getCodegenPreviewData(tableName); + return Result.success(list); + } + + @Operation(summary = "下载代码") + @GetMapping("/{tableName}/download") + @Log(value = "下载代码", module = LogModuleEnum.OTHER) + public void downloadZip(HttpServletResponse response, @PathVariable String tableName) { + String[] tableNames = tableName.split(","); + byte[] data = codegenService.downloadCode(tableNames); + + 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 file to response", e); + throw new RuntimeException("Failed to write the zip file to response", e); + } + } +} diff --git a/src/main/java/com/rnb/shared/codegen/converter/CodegenConverter.java b/src/main/java/com/rnb/shared/codegen/converter/CodegenConverter.java new file mode 100644 index 0000000..567b590 --- /dev/null +++ b/src/main/java/com/rnb/shared/codegen/converter/CodegenConverter.java @@ -0,0 +1,39 @@ +package com.rnb.shared.codegen.converter; + +import com.rnb.shared.codegen.model.entity.GenConfig; +import com.rnb.shared.codegen.model.entity.GenFieldConfig; +import com.rnb.shared.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 = "genConfig.tableName", target = "tableName") + @Mapping(source = "genConfig.businessName", target = "businessName") + @Mapping(source = "genConfig.moduleName", target = "moduleName") + @Mapping(source = "genConfig.packageName", target = "packageName") + @Mapping(source = "genConfig.entityName", target = "entityName") + @Mapping(source = "genConfig.author", target = "author") + @Mapping(source = "fieldConfigs", target = "fieldConfigs") + GenConfigForm toGenConfigForm(GenConfig genConfig, List fieldConfigs); + + List toGenFieldConfigForm(List fieldConfigs); + + GenConfigForm.FieldConfig toGenFieldConfigForm(GenFieldConfig genFieldConfig); + + GenConfig toGenConfig(GenConfigForm formData); + + List toGenFieldConfig(List fieldConfigs); + + GenFieldConfig toGenFieldConfig(GenConfigForm.FieldConfig fieldConfig); + +} \ No newline at end of file diff --git a/src/main/java/com/rnb/shared/codegen/enums/FormTypeEnum.java b/src/main/java/com/rnb/shared/codegen/enums/FormTypeEnum.java new file mode 100644 index 0000000..8448a5b --- /dev/null +++ b/src/main/java/com/rnb/shared/codegen/enums/FormTypeEnum.java @@ -0,0 +1,89 @@ +package com.rnb.shared.codegen.enums; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import com.rnb.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/rnb/shared/codegen/enums/JavaTypeEnum.java b/src/main/java/com/rnb/shared/codegen/enums/JavaTypeEnum.java new file mode 100644 index 0000000..cb08584 --- /dev/null +++ b/src/main/java/com/rnb/shared/codegen/enums/JavaTypeEnum.java @@ -0,0 +1,84 @@ +package com.rnb.shared.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", "Date"), + DATETIME("datetime", "LocalDateTime", "Date"); + + // 数据库类型 + 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) { + JavaTypeEnum javaTypeEnum = typeMap.get(columnType); + if (javaTypeEnum != null) { + return javaTypeEnum.getJavaType(); + } + return null; + } + + /** + * 根据Java类型获取对应的TypeScript类型 + * + * @param javaType Java类型 + * @return 对应的TypeScript类型 + */ + public static String getTsTypeByJavaType(String javaType) { + for (JavaTypeEnum javaTypeEnum : JavaTypeEnum.values()) { + if (javaTypeEnum.getJavaType().equals(javaType)) { + return javaTypeEnum.getTsType(); + } + } + return null; + } +} diff --git a/src/main/java/com/rnb/shared/codegen/enums/QueryTypeEnum.java b/src/main/java/com/rnb/shared/codegen/enums/QueryTypeEnum.java new file mode 100644 index 0000000..0dac261 --- /dev/null +++ b/src/main/java/com/rnb/shared/codegen/enums/QueryTypeEnum.java @@ -0,0 +1,73 @@ +package com.rnb.shared.codegen.enums; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import com.rnb.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/rnb/shared/codegen/mapper/DatabaseMapper.java b/src/main/java/com/rnb/shared/codegen/mapper/DatabaseMapper.java new file mode 100644 index 0000000..139c8d3 --- /dev/null +++ b/src/main/java/com/rnb/shared/codegen/mapper/DatabaseMapper.java @@ -0,0 +1,47 @@ +package com.rnb.shared.codegen.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.rnb.shared.codegen.model.bo.ColumnMetaData; +import com.rnb.shared.codegen.model.bo.TableMetaData; +import com.rnb.shared.codegen.model.query.TablePageQuery; +import com.rnb.shared.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, TablePageQuery queryParams); + + /** + * 获取表字段列表 + * + * @param tableName + * @return + */ + List getTableColumns(String tableName); + + /** + * 获取表元数据 + * + * @param tableName + * @return + */ + TableMetaData getTableMetadata(String tableName); +} diff --git a/src/main/java/com/rnb/shared/codegen/mapper/GenConfigMapper.java b/src/main/java/com/rnb/shared/codegen/mapper/GenConfigMapper.java new file mode 100644 index 0000000..01877dd --- /dev/null +++ b/src/main/java/com/rnb/shared/codegen/mapper/GenConfigMapper.java @@ -0,0 +1,20 @@ +package com.rnb.shared.codegen.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.rnb.shared.codegen.model.entity.GenConfig; +import org.apache.ibatis.annotations.Mapper; + +/** + * 代码生成基础配置访问层 + * + * @author Ray + * @since 2.10.0 + */ +@Mapper +public interface GenConfigMapper extends BaseMapper { + +} + + + + diff --git a/src/main/java/com/rnb/shared/codegen/mapper/GenFieldConfigMapper.java b/src/main/java/com/rnb/shared/codegen/mapper/GenFieldConfigMapper.java new file mode 100644 index 0000000..9f673b0 --- /dev/null +++ b/src/main/java/com/rnb/shared/codegen/mapper/GenFieldConfigMapper.java @@ -0,0 +1,20 @@ +package com.rnb.shared.codegen.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.rnb.shared.codegen.model.entity.GenFieldConfig; +import org.apache.ibatis.annotations.Mapper; + +/** + * 代码生成字段配置访问层 + * + * @author Ray + * @since 2.10.0 + */ +@Mapper +public interface GenFieldConfigMapper extends BaseMapper { + +} + + + + diff --git a/src/main/java/com/rnb/shared/codegen/model/bo/ColumnMetaData.java b/src/main/java/com/rnb/shared/codegen/model/bo/ColumnMetaData.java new file mode 100644 index 0000000..cd9add8 --- /dev/null +++ b/src/main/java/com/rnb/shared/codegen/model/bo/ColumnMetaData.java @@ -0,0 +1,50 @@ +package com.rnb.shared.codegen.model.bo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "数据表字段VO") +@Data +public class ColumnMetaData { + + /** + * 字段名称 + */ + private String columnName; + + /** + * 字段类型 + */ + private String dataType; + + /** + * 字段描述 + */ + private String columnComment; + + /** + * 字段长度 + */ + private Long characterMaximumLength; + + /** + * 是否主键(1-是 0-否) + */ + private Integer isPrimaryKey; + + /** + * 是否可为空(1-是 0-否) + */ + private String isNullable; + + /** + * 字符集 + */ + private String characterSetName; + + /** + * 排序规则 + */ + private String collationName; + +} diff --git a/src/main/java/com/rnb/shared/codegen/model/bo/TableMetaData.java b/src/main/java/com/rnb/shared/codegen/model/bo/TableMetaData.java new file mode 100644 index 0000000..290a249 --- /dev/null +++ b/src/main/java/com/rnb/shared/codegen/model/bo/TableMetaData.java @@ -0,0 +1,45 @@ +package com.rnb.shared.codegen.model.bo; + +import lombok.Data; + + +/** + * 数据表元数据 + * + * @author Ray + * @since 2.10.0 + */ +@Data +public class TableMetaData { + + /** + * 表名称 + */ + 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/rnb/shared/codegen/model/entity/GenConfig.java b/src/main/java/com/rnb/shared/codegen/model/entity/GenConfig.java new file mode 100644 index 0000000..a5f9e10 --- /dev/null +++ b/src/main/java/com/rnb/shared/codegen/model/entity/GenConfig.java @@ -0,0 +1,54 @@ +package com.rnb.shared.codegen.model.entity; + +import com.baomidou.mybatisplus.annotation.*; + +import com.rnb.common.base.BaseEntity; +import lombok.Getter; +import lombok.Setter; + +/** + * 代码生成基础配置 + * + * @author Ray + * @since 2.10.0 + */ +@TableName(value = "gen_config") +@Getter +@Setter +public class GenConfig extends BaseEntity { + + /** + * 表名 + */ + private String tableName; + + /** + * 包名 + */ + private String packageName; + + /** + * 模块名 + */ + private String moduleName; + + /** + * 实体类名 + */ + private String entityName; + + /** + * 业务名 + */ + private String businessName; + + /** + * 父菜单ID + */ + private Long parentMenuId; + + /** + * 作者 + */ + private String author; +} \ No newline at end of file diff --git a/src/main/java/com/rnb/shared/codegen/model/entity/GenFieldConfig.java b/src/main/java/com/rnb/shared/codegen/model/entity/GenFieldConfig.java new file mode 100644 index 0000000..8d58c84 --- /dev/null +++ b/src/main/java/com/rnb/shared/codegen/model/entity/GenFieldConfig.java @@ -0,0 +1,106 @@ +package com.rnb.shared.codegen.model.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.rnb.common.base.BaseEntity; +import com.rnb.shared.codegen.enums.FormTypeEnum; +import com.rnb.shared.codegen.enums.QueryTypeEnum; +import lombok.Getter; +import lombok.Setter; + +/** + * 字段生成配置实体 + * + * @author Ray + * @since 2.10.0 + */ +@TableName(value = "gen_field_config") +@Getter +@Setter +public class GenFieldConfig extends BaseEntity { + + + /** + * 关联的配置ID + */ + private Long configId; + + /** + * 列名 + */ + 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/rnb/shared/codegen/model/form/GenConfigForm.java b/src/main/java/com/rnb/shared/codegen/model/form/GenConfigForm.java new file mode 100644 index 0000000..cfbf108 --- /dev/null +++ b/src/main/java/com/rnb/shared/codegen/model/form/GenConfigForm.java @@ -0,0 +1,103 @@ +package com.rnb.shared.codegen.model.form; + +import com.rnb.shared.codegen.enums.FormTypeEnum; +import com.rnb.shared.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.rnb") + private String packageName; + + @Schema(description = "实体名",example = "User") + private String entityName; + + @Schema(description = "作者",example = "Alex.Q") + 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 = "字段配置") + @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/rnb/shared/codegen/model/query/TablePageQuery.java b/src/main/java/com/rnb/shared/codegen/model/query/TablePageQuery.java new file mode 100644 index 0000000..7e7d06b --- /dev/null +++ b/src/main/java/com/rnb/shared/codegen/model/query/TablePageQuery.java @@ -0,0 +1,31 @@ +package com.rnb.shared.codegen.model.query; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.rnb.common.base.BasePageQuery; +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 BasePageQuery { + + @Schema(description="关键字(表名)") + private String keywords; + + /** + * 排除的表名 + */ + @JsonIgnore + private List excludeTables; + +} diff --git a/src/main/java/com/rnb/shared/codegen/model/vo/CodegenPreviewVO.java b/src/main/java/com/rnb/shared/codegen/model/vo/CodegenPreviewVO.java new file mode 100644 index 0000000..9bd3142 --- /dev/null +++ b/src/main/java/com/rnb/shared/codegen/model/vo/CodegenPreviewVO.java @@ -0,0 +1,19 @@ +package com.rnb.shared.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; + +} diff --git a/src/main/java/com/rnb/shared/codegen/model/vo/TablePageVO.java b/src/main/java/com/rnb/shared/codegen/model/vo/TablePageVO.java new file mode 100644 index 0000000..1f0fc8d --- /dev/null +++ b/src/main/java/com/rnb/shared/codegen/model/vo/TablePageVO.java @@ -0,0 +1,32 @@ +package com.rnb.shared.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/rnb/shared/codegen/service/CodegenService.java b/src/main/java/com/rnb/shared/codegen/service/CodegenService.java new file mode 100644 index 0000000..e81157f --- /dev/null +++ b/src/main/java/com/rnb/shared/codegen/service/CodegenService.java @@ -0,0 +1,40 @@ +package com.rnb.shared.codegen.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.rnb.shared.codegen.model.query.TablePageQuery; +import com.rnb.shared.codegen.model.vo.CodegenPreviewVO; +import com.rnb.shared.codegen.model.vo.TablePageVO; + +import java.util.List; + +/** + * 代码生成配置接口 + * + * @author Ray + * @since 2.10.0 + */ +public interface CodegenService { + + /** + * 获取数据表分页列表 + * + * @param queryParams 查询参数 + * @return + */ + Page getTablePage(TablePageQuery queryParams); + + /** + * 获取预览生成代码 + * + * @param tableName 表名 + * @return + */ + List getCodegenPreviewData(String tableName); + + /** + * 下载代码 + * @param tableNames 表名 + * @return + */ + byte[] downloadCode(String[] tableNames); +} diff --git a/src/main/java/com/rnb/shared/codegen/service/GenConfigService.java b/src/main/java/com/rnb/shared/codegen/service/GenConfigService.java new file mode 100644 index 0000000..fc924f6 --- /dev/null +++ b/src/main/java/com/rnb/shared/codegen/service/GenConfigService.java @@ -0,0 +1,39 @@ +package com.rnb.shared.codegen.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.rnb.shared.codegen.model.entity.GenConfig; +import com.rnb.shared.codegen.model.form.GenConfigForm; + +/** + * 代码生成配置接口 + * + * @author Ray + * @since 2.10.0 + */ +public interface GenConfigService extends IService { + + /** + * 获取代码生成配置 + * + * @param tableName 表名 + * @return + */ + GenConfigForm getGenConfigFormData(String tableName); + + /** + * 保存代码生成配置 + * + * @param formData 表单数据 + * @return + */ + void saveGenConfig(GenConfigForm formData); + + /** + * 删除代码生成配置 + * + * @param tableName 表名 + * @return + */ + void deleteGenConfig(String tableName); + +} diff --git a/src/main/java/com/rnb/shared/codegen/service/GenFieldConfigService.java b/src/main/java/com/rnb/shared/codegen/service/GenFieldConfigService.java new file mode 100644 index 0000000..1ca7ebe --- /dev/null +++ b/src/main/java/com/rnb/shared/codegen/service/GenFieldConfigService.java @@ -0,0 +1,14 @@ +package com.rnb.shared.codegen.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.rnb.shared.codegen.model.entity.GenFieldConfig; + +/** + * 代码生成配置接口 + * + * @author Ray + * @since 2.10.0 + */ +public interface GenFieldConfigService extends IService { + +} diff --git a/src/main/java/com/rnb/shared/codegen/service/impl/CodegenServiceImpl.java b/src/main/java/com/rnb/shared/codegen/service/impl/CodegenServiceImpl.java new file mode 100644 index 0000000..3ee0db3 --- /dev/null +++ b/src/main/java/com/rnb/shared/codegen/service/impl/CodegenServiceImpl.java @@ -0,0 +1,316 @@ +package com.rnb.shared.codegen.service.impl; + +import cn.hutool.core.collection.CollectionUtil; +import cn.hutool.core.date.DateUtil; +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.rnb.shared.codegen.enums.JavaTypeEnum; +import com.rnb.config.property.CodegenProperties; +import com.rnb.shared.codegen.service.GenConfigService; +import com.rnb.shared.codegen.service.GenFieldConfigService; +import com.rnb.shared.codegen.service.CodegenService; +import com.rnb.common.exception.BusinessException; +import com.rnb.shared.codegen.mapper.DatabaseMapper; +import com.rnb.shared.codegen.model.entity.GenConfig; +import com.rnb.shared.codegen.model.entity.GenFieldConfig; +import com.rnb.shared.codegen.model.query.TablePageQuery; +import com.rnb.shared.codegen.model.vo.CodegenPreviewVO; +import com.rnb.shared.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; + +/** + * 数据库服务实现类 + * + * @author Ray + * @since 2.10.0 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class CodegenServiceImpl implements CodegenService { + + private final DatabaseMapper databaseMapper; + private final CodegenProperties codegenProperties; + private final GenConfigService genConfigService; + private final GenFieldConfigService genFieldConfigService; + + /** + * 数据表分页列表 + * + * @param queryParams 查询参数 + * @return 分页结果 + */ + public Page getTablePage(TablePageQuery queryParams) { + Page page = new Page<>(queryParams.getPageNum(), queryParams.getPageSize()); + // 设置排除的表 + List excludeTables = codegenProperties.getExcludeTables(); + queryParams.setExcludeTables(excludeTables); + + return databaseMapper.getTablePage(page, queryParams); + } + + /** + * 获取预览生成代码 + * + * @param tableName 表名 + * @return 预览数据 + */ + @Override + public List getCodegenPreviewData(String tableName) { + + List list = new ArrayList<>(); + + GenConfig genConfig = genConfigService.getOne(new LambdaQueryWrapper() + .eq(GenConfig::getTableName, tableName) + ); + if (genConfig == null) { + throw new BusinessException("未找到表生成配置"); + } + + List fieldConfigs = genFieldConfigService.list(new LambdaQueryWrapper() + .eq(GenFieldConfig::getConfigId, genConfig.getId()) + .orderByAsc(GenFieldConfig::getFieldSort) + + ); + if (CollectionUtil.isEmpty(fieldConfigs)) { + throw new BusinessException("未找到字段生成配置"); + } + + // 遍历模板配置 + Map templateConfigs = codegenProperties.getTemplateConfigs(); + for (Map.Entry templateConfigEntry : templateConfigs.entrySet()) { + CodegenPreviewVO previewVO = new CodegenPreviewVO(); + + CodegenProperties.TemplateConfig templateConfig = templateConfigEntry.getValue(); + + /* 1. 生成文件名 UserController */ + // User Role Menu Dept + String entityName = genConfig.getEntityName(); + // Controller Service Mapper Entity + String templateName = templateConfigEntry.getKey(); + // .java .ts .vue + String extension = templateConfig.getExtension(); + + // 文件名 UserController.java + String fileName = getFileName(entityName, templateName, extension); + previewVO.setFileName(fileName); + + /* 2. 生成文件路径 */ + // 包名:com.rnb + String packageName = genConfig.getPackageName(); + // 模块名:system + String moduleName = genConfig.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. 生成文件内容 */ + // 将模板文件中的变量替换为具体的值 生成代码内容 + String content = getCodeContent(templateConfig, genConfig, fieldConfigs); + previewVO.setContent(content); + + list.add(previewVO); + } + return list; + } + + /** + * 生成文件名 + * + * @param entityName 实体类名 UserController + * @param templateName 模板名 Entity + * @param extension 文件后缀 .java + * @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 StrUtil.toSymbolCase(entityName, '-') + extension; + } else if ("VIEW".equals(templateName)) { + return "index.vue"; + } + return entityName + templateName + extension; + } + + /** + * 生成文件路径 + * + * @param templateName 模板名 Entity + * @param moduleName 模块名 system + * @param packageName 包名 com.rnb + * @param subPackageName 子包名 controller + * @param entityName 实体类名 UserController + * @return 文件路径 src/main/java/com/youlai/system/controller + */ + 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 = "src/api/system"; + path = (codegenProperties.getFrontendAppName() + + File.separator + "src" + + File.separator + subPackageName + + File.separator + moduleName + ); + } 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 genConfig 生成配置 + * @param fieldConfigs 字段配置 + * @return 代码内容 + */ + private String getCodeContent(CodegenProperties.TemplateConfig templateConfig, GenConfig genConfig, List fieldConfigs) { + + Map bindMap = new HashMap<>(); + + String entityName = genConfig.getEntityName(); + + bindMap.put("packageName", genConfig.getPackageName()); + bindMap.put("moduleName", genConfig.getModuleName()); + bindMap.put("subpackageName", templateConfig.getSubpackageName()); + bindMap.put("date", DateUtil.format(new Date(), "yyyy-MM-dd HH:mm")); + bindMap.put("entityName", entityName); + bindMap.put("tableName", genConfig.getTableName()); + bindMap.put("author", genConfig.getAuthor()); + bindMap.put("lowerFirstEntityName", StrUtil.lowerFirst(entityName)); // UserTest → userTest + bindMap.put("kebabCaseEntityName", StrUtil.toSymbolCase(entityName, '-')); // UserTest → user-test + bindMap.put("businessName", genConfig.getBusinessName()); + bindMap.put("fieldConfigs", fieldConfigs); + + boolean hasLocalDateTime = false; + boolean hasBigDecimal = false; + boolean hasRequiredField = false; + + for (GenFieldConfig fieldConfig : fieldConfigs) { + + if ("LocalDateTime".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)); + Template template = templateEngine.getTemplate(templateConfig.getTemplatePath()); + + return template.render(bindMap); + } + + /** + * 下载代码 + * + * @param tableNames 表名数组,支持多张表。 + * @return 压缩文件字节数组 + */ + @Override + public byte[] downloadCode(String[] tableNames) { + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ZipOutputStream zip = new ZipOutputStream(outputStream)) { + + // 遍历每个表名,生成对应的代码并压缩到 zip 文件中 + for (String tableName : tableNames) { + generateAndZipCode(tableName, zip); + } + // 确保所有压缩数据写入输出流,避免数据残留在内存缓冲区引发的数据不完整 + 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 file", e); + } + } + + /** + * 根据表名生成代码并压缩到zip文件中 + * + * @param tableName 表名 + * @param zip 压缩文件输出流 + */ + private void generateAndZipCode(String tableName, ZipOutputStream zip) { + List codePreviewList = getCodegenPreviewData(tableName); + + 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 file {} to zip", fileName, e); + } + } + } + +} diff --git a/src/main/java/com/rnb/shared/codegen/service/impl/GenConfigServiceImpl.java b/src/main/java/com/rnb/shared/codegen/service/impl/GenConfigServiceImpl.java new file mode 100644 index 0000000..009b19c --- /dev/null +++ b/src/main/java/com/rnb/shared/codegen/service/impl/GenConfigServiceImpl.java @@ -0,0 +1,221 @@ +package com.rnb.shared.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.rnb.RnBApplication; +import com.rnb.common.enums.EnvEnum; +import com.rnb.shared.codegen.enums.FormTypeEnum; +import com.rnb.shared.codegen.enums.JavaTypeEnum; +import com.rnb.shared.codegen.enums.QueryTypeEnum; +import com.rnb.common.exception.BusinessException; +import com.rnb.config.property.CodegenProperties; +import com.rnb.shared.codegen.converter.CodegenConverter; +import com.rnb.shared.codegen.mapper.DatabaseMapper; +import com.rnb.shared.codegen.mapper.GenConfigMapper; +import com.rnb.shared.codegen.model.bo.ColumnMetaData; +import com.rnb.shared.codegen.model.bo.TableMetaData; +import com.rnb.shared.codegen.model.entity.GenConfig; +import com.rnb.shared.codegen.model.entity.GenFieldConfig; +import com.rnb.shared.codegen.model.form.GenConfigForm; +import com.rnb.shared.codegen.service.GenConfigService; +import com.rnb.shared.codegen.service.GenFieldConfigService; +import com.rnb.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 GenConfigServiceImpl extends ServiceImpl implements GenConfigService { + + private final DatabaseMapper databaseMapper; + private final CodegenProperties codegenProperties; + private final GenFieldConfigService genFieldConfigService; + private final CodegenConverter codegenConverter; + + @Value("${spring.profiles.active}") + private String springProfilesActive; + + private final MenuService menuService; + + /** + * 获取代码生成配置 + * + * @param tableName 表名 eg: sys_user + * @return 代码生成配置 + */ + @Override + public GenConfigForm getGenConfigFormData(String tableName) { + // 查询表生成配置 + GenConfig genConfig = this.getOne( + new LambdaQueryWrapper<>(GenConfig.class) + .eq(GenConfig::getTableName, tableName) + .last("LIMIT 1") + ); + + // 是否有代码生成配置 + boolean hasGenConfig = genConfig != null; + + // 如果没有代码生成配置,则根据表的元数据生成默认配置 + if (genConfig == null) { + TableMetaData tableMetadata = databaseMapper.getTableMetadata(tableName); + Assert.isTrue(tableMetadata != null, "未找到表元数据"); + + genConfig = new GenConfig(); + genConfig.setTableName(tableName); + + // 表注释作为业务名称,去掉表字 例如:用户表 -> 用户 + String tableComment = tableMetadata.getTableComment(); + if (StrUtil.isNotBlank(tableComment)) { + genConfig.setBusinessName(tableComment.replace("表", "").trim()); + } + // 根据表名生成实体类名 例如:sys_user -> SysUser + genConfig.setEntityName(StrUtil.toCamelCase(StrUtil.upperFirst(StrUtil.toCamelCase(tableName)))); + + genConfig.setPackageName(RnBApplication.class.getPackageName()); + genConfig.setModuleName(codegenProperties.getDefaultConfig().getModuleName()); // 默认模块名 + genConfig.setAuthor(codegenProperties.getDefaultConfig().getAuthor()); + } + + // 根据表的列 + 已经存在的字段生成配置 得到 组合后的字段生成配置 + List genFieldConfigs = new ArrayList<>(); + + // 获取表的列 + List tableColumns = databaseMapper.getTableColumns(tableName); + if (CollectionUtil.isNotEmpty(tableColumns)) { + // 查询字段生成配置 + List fieldConfigList = genFieldConfigService.list( + new LambdaQueryWrapper() + .eq(GenFieldConfig::getConfigId, genConfig.getId()) + .orderByAsc(GenFieldConfig::getFieldSort) + ); + Integer maxSort = fieldConfigList.stream() + .map(GenFieldConfig::getFieldSort) + .filter(Objects::nonNull) // 过滤掉空值 + .max(Integer::compareTo) + .orElse(0); + for (ColumnMetaData tableColumn : tableColumns) { + // 根据列名获取字段生成配置 + String columnName = tableColumn.getColumnName(); + GenFieldConfig 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 (!hasGenConfig) { + fieldConfig.setIsShowInList(1); + fieldConfig.setIsShowInForm(1); + } + genFieldConfigs.add(fieldConfig); + } + } + // 对 genFieldConfigs 按照 fieldSort 排序 + genFieldConfigs = genFieldConfigs.stream().sorted(Comparator.comparing(GenFieldConfig::getFieldSort)).toList(); + GenConfigForm genConfigForm = codegenConverter.toGenConfigForm(genConfig, genFieldConfigs); + + genConfigForm.setFrontendAppName(codegenProperties.getFrontendAppName()); + genConfigForm.setBackendAppName(codegenProperties.getBackendAppName()); + return genConfigForm; + } + + + /** + * 创建默认字段配置 + * + * @param columnMetaData 表字段元数据 + * @return + */ + private GenFieldConfig createDefaultFieldConfig(ColumnMetaData columnMetaData) { + GenFieldConfig fieldConfig = new GenFieldConfig(); + fieldConfig.setColumnName(columnMetaData.getColumnName()); + fieldConfig.setColumnType(columnMetaData.getDataType()); + fieldConfig.setFieldComment(columnMetaData.getColumnComment()); + fieldConfig.setFieldName(StrUtil.toCamelCase(columnMetaData.getColumnName())); + fieldConfig.setIsRequired("YES".equals(columnMetaData.getIsNullable()) ? 0 : 1); + + if (fieldConfig.getColumnType().equals("date")) { + fieldConfig.setFormType(FormTypeEnum.DATE); + } else if (fieldConfig.getColumnType().equals("datetime")) { + fieldConfig.setFormType(FormTypeEnum.DATE_TIME); + } else { + fieldConfig.setFormType(FormTypeEnum.INPUT); + } + + fieldConfig.setQueryType(QueryTypeEnum.EQ); + fieldConfig.setMaxLength(columnMetaData.getCharacterMaximumLength()); + return fieldConfig; + } + + /** + * 保存代码生成配置 + * + * @param formData 代码生成配置表单 + */ + @Override + public void saveGenConfig(GenConfigForm formData) { + GenConfig genConfig = codegenConverter.toGenConfig(formData); + this.saveOrUpdate(genConfig); + + // 如果选择上级菜单且当前环境不是生产环境,则保存菜单 + Long parentMenuId = formData.getParentMenuId(); + if (parentMenuId != null && !EnvEnum.PROD.getValue().equals(springProfilesActive)) { + menuService.addMenuForCodegen(parentMenuId, genConfig); + } + + List genFieldConfigs = codegenConverter.toGenFieldConfig(formData.getFieldConfigs()); + + if (CollectionUtil.isEmpty(genFieldConfigs)) { + throw new BusinessException("字段配置不能为空"); + } + genFieldConfigs.forEach(genFieldConfig -> { + genFieldConfig.setConfigId(genConfig.getId()); + }); + genFieldConfigService.saveOrUpdateBatch(genFieldConfigs); + } + + /** + * 删除代码生成配置 + * + * @param tableName 表名 + */ + @Override + public void deleteGenConfig(String tableName) { + GenConfig genConfig = this.getOne(new LambdaQueryWrapper() + .eq(GenConfig::getTableName, tableName)); + + boolean result = this.remove(new LambdaQueryWrapper() + .eq(GenConfig::getTableName, tableName) + ); + if (result) { + genFieldConfigService.remove(new LambdaQueryWrapper() + .eq(GenFieldConfig::getConfigId, genConfig.getId()) + ); + } + } + + + +} diff --git a/src/main/java/com/rnb/shared/codegen/service/impl/GenFieldConfigServiceImpl.java b/src/main/java/com/rnb/shared/codegen/service/impl/GenFieldConfigServiceImpl.java new file mode 100644 index 0000000..9cfd848 --- /dev/null +++ b/src/main/java/com/rnb/shared/codegen/service/impl/GenFieldConfigServiceImpl.java @@ -0,0 +1,21 @@ +package com.rnb.shared.codegen.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.rnb.shared.codegen.mapper.GenFieldConfigMapper; +import com.rnb.shared.codegen.model.entity.GenFieldConfig; +import com.rnb.shared.codegen.service.GenFieldConfigService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * 代码生成字段配置服务实现类 + * + * @author Ray + * @since 2.10.0 + */ +@Service +@RequiredArgsConstructor +public class GenFieldConfigServiceImpl extends ServiceImpl implements GenFieldConfigService { + + +} diff --git a/src/main/java/com/rnb/shared/file/controller/FileController.java b/src/main/java/com/rnb/shared/file/controller/FileController.java new file mode 100644 index 0000000..b72b056 --- /dev/null +++ b/src/main/java/com/rnb/shared/file/controller/FileController.java @@ -0,0 +1,60 @@ +package com.rnb.shared.file.controller; + +import com.rnb.common.result.Result; +import com.rnb.shared.file.service.FileService; +import com.rnb.shared.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 = "07.文件接口") +@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, @RequestParam(required = false) String path + ) { + FileInfo fileInfo; + if(path==null || path.isEmpty()) + fileInfo = fileService.uploadFile(file); + else + fileInfo = fileService.uploadFile(file, path); + + 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/rnb/shared/file/model/FileInfo.java b/src/main/java/com/rnb/shared/file/model/FileInfo.java new file mode 100644 index 0000000..3b32508 --- /dev/null +++ b/src/main/java/com/rnb/shared/file/model/FileInfo.java @@ -0,0 +1,23 @@ +package com.rnb.shared.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/rnb/shared/file/service/FileService.java b/src/main/java/com/rnb/shared/file/service/FileService.java new file mode 100644 index 0000000..82c00e1 --- /dev/null +++ b/src/main/java/com/rnb/shared/file/service/FileService.java @@ -0,0 +1,40 @@ +package com.rnb.shared.file.service; + +import com.rnb.shared.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 file 表单文件对象 + * @param path 文件路径 + * @return 文件信息 + */ + default FileInfo uploadFile(MultipartFile file, String path){ + return null; + }; + + /** + * 删除文件 + * + * @param filePath 文件完整URL + * @return 删除结果 + */ + boolean deleteFile(String filePath); + + +} diff --git a/src/main/java/com/rnb/shared/file/service/impl/AliyunFileService.java b/src/main/java/com/rnb/shared/file/service/impl/AliyunFileService.java new file mode 100644 index 0000000..928c0c3 --- /dev/null +++ b/src/main/java/com/rnb/shared/file/service/impl/AliyunFileService.java @@ -0,0 +1,100 @@ +package com.rnb.shared.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.rnb.shared.file.service.FileService; +import com.rnb.shared.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/rnb/shared/file/service/impl/LocalFileService.java b/src/main/java/com/rnb/shared/file/service/impl/LocalFileService.java new file mode 100644 index 0000000..27b1ec8 --- /dev/null +++ b/src/main/java/com/rnb/shared/file/service/impl/LocalFileService.java @@ -0,0 +1,127 @@ +package com.rnb.shared.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.rnb.shared.file.model.FileInfo; +import com.rnb.shared.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 file 表单文件对象 + * @param path 文件路径 + * @return 文件信息 + */ + @Override + public FileInfo uploadFile(MultipartFile file, String path) { + // 获取文件名 + String fileName = FileUtil.getName(file.getOriginalFilename());// .getSuffix(originalFilename); + String filePrefix = storagePath.endsWith(File.separator) ? storagePath : storagePath + File.separator; + + String fileDir = filePrefix + path; + File dirFile = new File(fileDir); + if (!dirFile.exists()) { + if (!dirFile.mkdirs()) + return null; + } + // try-with-resource 语法糖自动释放流 + try (InputStream inputStream = file.getInputStream()) { + // 上传文件 + FileUtil.writeFromStream(inputStream, fileDir + fileName); + } catch (Exception e) { + log.error("文件上传失败", e); + throw new RuntimeException("文件上传失败"); + } + // 获取文件访问路径,因为这里是本地存储,所以直接返回文件的相对路径,需要前端自行处理访问前缀 +// String fileUrl = "/files/" + path + fileName; + FileInfo fileInfo = new FileInfo(); + fileInfo.setName(file.getOriginalFilename()); + fileInfo.setUrl(File.separator + path + fileName); + 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/rnb/shared/file/service/impl/MinioFileService.java b/src/main/java/com/rnb/shared/file/service/impl/MinioFileService.java new file mode 100644 index 0000000..e52f972 --- /dev/null +++ b/src/main/java/com/rnb/shared/file/service/impl/MinioFileService.java @@ -0,0 +1,210 @@ +package com.rnb.shared.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.rnb.common.exception.BusinessException; +import com.rnb.common.result.ResultCode; +import com.rnb.shared.file.model.FileInfo; +import com.rnb.shared.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/rnb/shared/mail/controller/MailController.java b/src/main/java/com/rnb/shared/mail/controller/MailController.java new file mode 100644 index 0000000..f195277 --- /dev/null +++ b/src/main/java/com/rnb/shared/mail/controller/MailController.java @@ -0,0 +1,14 @@ +package com.rnb.shared.mail.controller; + +import org.springframework.web.bind.annotation.*; + +/** + * 邮件控制层 + * + * @author Ray.Hao + * @since 2.10.0 + */ +@RestController +public class MailController { + +} diff --git a/src/main/java/com/rnb/shared/mail/service/MailService.java b/src/main/java/com/rnb/shared/mail/service/MailService.java new file mode 100644 index 0000000..7ea3cfd --- /dev/null +++ b/src/main/java/com/rnb/shared/mail/service/MailService.java @@ -0,0 +1,31 @@ +package com.rnb.shared.mail.service; + +/** + * 邮件服务接口层 + * + * @author Ray + * @since 2024/8/17 + */ +public interface MailService { + + + /** + * 发送简单文本邮件 + * + * @param to 收件人地址 + * @param subject 邮件主题 + * @param text 邮件内容 + */ + void sendMail(String to, String subject, String text) ; + + /** + * 发送带附件的邮件 + * + * @param to 收件人地址 + * @param subject 邮件主题 + * @param text 邮件内容 + * @param filePath 附件路径 + */ + void sendMailWithAttachment(String to, String subject, String text, String filePath); + +} diff --git a/src/main/java/com/rnb/shared/mail/service/impl/MailServiceImpl.java b/src/main/java/com/rnb/shared/mail/service/impl/MailServiceImpl.java new file mode 100644 index 0000000..0e4ac1b --- /dev/null +++ b/src/main/java/com/rnb/shared/mail/service/impl/MailServiceImpl.java @@ -0,0 +1,79 @@ +package com.rnb.shared.mail.service.impl; + +import com.rnb.config.property.MailProperties; +import com.rnb.shared.mail.service.MailService; +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 + * @since 2024/8/17 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class MailServiceImpl implements MailService { + + private final JavaMailSender mailSender; + + private final MailProperties mailProperties; + + /** + * 发送简单文本邮件 + * + * @param to 收件人地址 + * @param subject 邮件主题 + * @param text 邮件内容 + */ + @Override + 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 附件路径 + */ + @Override + 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/rnb/shared/sms/controller/SmsController.java b/src/main/java/com/rnb/shared/sms/controller/SmsController.java new file mode 100644 index 0000000..5cc6b60 --- /dev/null +++ b/src/main/java/com/rnb/shared/sms/controller/SmsController.java @@ -0,0 +1,14 @@ +package com.rnb.shared.sms.controller; + +/** + * 短信控制层 + * + * @author Ray + * @since 2.10.0 + */ + +public class SmsController { + + + +} diff --git a/src/main/java/com/rnb/shared/sms/enums/SmsTypeEnum.java b/src/main/java/com/rnb/shared/sms/enums/SmsTypeEnum.java new file mode 100644 index 0000000..9e05395 --- /dev/null +++ b/src/main/java/com/rnb/shared/sms/enums/SmsTypeEnum.java @@ -0,0 +1,39 @@ +package com.rnb.shared.sms.enums; + +import com.rnb.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/rnb/shared/sms/service/SmsService.java b/src/main/java/com/rnb/shared/sms/service/SmsService.java new file mode 100644 index 0000000..2aa873b --- /dev/null +++ b/src/main/java/com/rnb/shared/sms/service/SmsService.java @@ -0,0 +1,24 @@ +package com.rnb.shared.sms.service; + +import com.rnb.shared.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/rnb/shared/sms/service/impl/AliyunSmsService.java b/src/main/java/com/rnb/shared/sms/service/impl/AliyunSmsService.java new file mode 100644 index 0000000..822a8bc --- /dev/null +++ b/src/main/java/com/rnb/shared/sms/service/impl/AliyunSmsService.java @@ -0,0 +1,79 @@ +package com.rnb.shared.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.rnb.config.property.AliyunSmsProperties; +import com.rnb.shared.sms.enums.SmsTypeEnum; +import com.rnb.shared.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/rnb/shared/websocket/controller/WebsocketController.java b/src/main/java/com/rnb/shared/websocket/controller/WebsocketController.java new file mode 100644 index 0000000..4d03e9a --- /dev/null +++ b/src/main/java/com/rnb/shared/websocket/controller/WebsocketController.java @@ -0,0 +1,64 @@ +package com.rnb.shared.websocket.controller; + +import com.rnb.shared.websocket.model.ChatMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.security.Principal; + +/** + * WebSocket 测试用例控制层 + *

+ * 包含点对点/广播发送消息 + * + * @author Ray.Hao + * @since 2.3.0 + */ +@RestController +@RequestMapping("/websocket") +@RequiredArgsConstructor +@Slf4j +public class WebsocketController { + + private final SimpMessagingTemplate messagingTemplate; + + + /** + * 广播发送消息 + * + * @param message 消息内容 + */ + @MessageMapping("/sendToAll") + @SendTo("/topic/notice") + public String sendToAll(String message) { + return "服务端通知: " + message; + } + + /** + * 点对点发送消息 + *

+ * 模拟 张三 给 李四 发送消息场景 + * + * @param principal 当前用户 + * @param username 接收消息的用户 + * @param message 消息内容 + */ + @MessageMapping("/sendToUser/{username}") + public void sendToUser(Principal principal, @DestinationVariable String username, String message) { + // 发送人 + String sender = principal.getName(); + // 接收人 + String receiver = username; + + log.info("发送人:{}; 接收人:{}", sender, receiver); + // 发送消息给指定用户,拼接后路径 /user/{receiver}/queue/greeting + messagingTemplate.convertAndSendToUser(receiver, "/queue/greeting", new ChatMessage(sender, message)); + } + +} diff --git a/src/main/java/com/rnb/shared/websocket/model/ChatMessage.java b/src/main/java/com/rnb/shared/websocket/model/ChatMessage.java new file mode 100644 index 0000000..89f85cf --- /dev/null +++ b/src/main/java/com/rnb/shared/websocket/model/ChatMessage.java @@ -0,0 +1,25 @@ +package com.rnb.shared.websocket.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 系统消息体 + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ChatMessage { + + /** + * 发送者 + */ + private String sender; + + /** + * 消息内容 + */ + private String content; + +} diff --git a/src/main/java/com/rnb/system/controller/ConfigController.java b/src/main/java/com/rnb/system/controller/ConfigController.java new file mode 100644 index 0000000..bdccb61 --- /dev/null +++ b/src/main/java/com/rnb/system/controller/ConfigController.java @@ -0,0 +1,87 @@ +package com.rnb.system.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.rnb.common.enums.LogModuleEnum; +import com.rnb.common.result.PageResult; +import com.rnb.common.result.Result; +import com.rnb.common.annotation.Log; +import com.rnb.system.model.form.ConfigForm; +import com.rnb.system.model.query.ConfigPageQuery; +import com.rnb.system.model.vo.ConfigVO; +import com.rnb.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/config") +public class ConfigController { + + private final ConfigService configService; + + @Operation(summary = "系统配置分页列表") + @GetMapping("/page") + @PreAuthorize("@ss.hasPerm('sys:config:query')") + @Log( value = "系统配置分页列表",module = LogModuleEnum.SETTING) + public PageResult page(@ParameterObject ConfigPageQuery configPageQuery) { + IPage result = configService.page(configPageQuery); + return PageResult.success(result); + } + + @Operation(summary = "新增系统配置") + @PostMapping + @PreAuthorize("@ss.hasPerm('sys:config:add')") + @Log( value = "新增系统配置",module = LogModuleEnum.SETTING) + 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( value = "刷新系统配置缓存",module = LogModuleEnum.SETTING) + public Result refreshCache() { + return Result.judge(configService.refreshCache()); + } + + @Operation(summary = "修改系统配置") + @PutMapping(value = "/{id}") + @PreAuthorize("@ss.hasPerm('sys:config:update')") + @Log( value = "修改系统配置",module = LogModuleEnum.SETTING) + 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( value = "删除系统配置",module = LogModuleEnum.SETTING) + public Result delete(@PathVariable Long id) { + return Result.judge(configService.delete(id)); + } + +} diff --git a/src/main/java/com/rnb/system/controller/DeptController.java b/src/main/java/com/rnb/system/controller/DeptController.java new file mode 100644 index 0000000..e05b61b --- /dev/null +++ b/src/main/java/com/rnb/system/controller/DeptController.java @@ -0,0 +1,94 @@ +package com.rnb.system.controller; + +import com.rnb.common.enums.LogModuleEnum; +import com.rnb.common.annotation.RepeatSubmit; +import com.rnb.common.model.Option; +import com.rnb.common.result.Result; +import com.rnb.system.model.form.DeptForm; +import com.rnb.system.model.query.DeptQuery; +import com.rnb.system.model.vo.DeptVO; +import com.rnb.common.annotation.Log; +import com.rnb.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 haoxr + * @since 2020/11/6 + */ +@Tag(name = "05.部门接口") +@RestController +@RequestMapping("/api/v1/dept") +@RequiredArgsConstructor +public class DeptController { + + private final DeptService deptService; + + @Operation(summary = "部门列表") + @GetMapping + @Log( value = "部门列表",module = LogModuleEnum.DEPT) + 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:add')") + @RepeatSubmit + 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:edit')") + 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')") + 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/rnb/system/controller/DictController.java b/src/main/java/com/rnb/system/controller/DictController.java new file mode 100644 index 0000000..3cdce55 --- /dev/null +++ b/src/main/java/com/rnb/system/controller/DictController.java @@ -0,0 +1,214 @@ +package com.rnb.system.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.rnb.common.model.Option; +import com.rnb.common.result.PageResult; +import com.rnb.common.result.Result; +import com.rnb.common.enums.LogModuleEnum; +import com.rnb.system.model.form.DictItemForm; +import com.rnb.system.model.query.DictItemPageQuery; +import com.rnb.system.model.query.DictPageQuery; +import com.rnb.system.model.vo.DictItemOptionVO; +import com.rnb.system.model.vo.DictItemPageVO; +import com.rnb.system.model.vo.DictPageVO; +import com.rnb.common.annotation.RepeatSubmit; +import com.rnb.system.model.form.DictForm; +import com.rnb.common.annotation.Log; +import com.rnb.system.service.DictItemService; +import com.rnb.system.service.DictService; +import com.rnb.system.service.WebSocketService; +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 = "06.字典接口") +@RestController +@SuppressWarnings("SpellCheckingInspection") +@RequestMapping("/api/v1/dicts") +@RequiredArgsConstructor +public class DictController { + + private final DictService dictService; + private final DictItemService dictItemService; + private final WebSocketService webSocketService; + + //--------------------------------------------------- + // 字典相关接口 + //--------------------------------------------------- + @Operation(summary = "字典分页列表") + @GetMapping("/page") + @Log( value = "字典分页列表",module = LogModuleEnum.DICT) + public PageResult getDictPage( + DictPageQuery queryParams + ) { + Page result = dictService.getDictPage(queryParams); + return PageResult.success(result); + } + + + @Operation(summary = "字典列表") + @GetMapping + 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:add')") + @RepeatSubmit + public Result saveDict(@Valid @RequestBody DictForm formData) { + boolean result = dictService.saveDict(formData); + // 发送字典更新通知 + if (result) { + webSocketService.broadcastDictChange(formData.getDictCode()); + } + return Result.judge(result); + } + + @Operation(summary = "修改字典") + @PutMapping("/{id}") + @PreAuthorize("@ss.hasPerm('sys:dict:edit')") + public Result updateDict( + @PathVariable Long id, + @RequestBody DictForm dictForm + ) { + boolean status = dictService.updateDict(id, dictForm); + // 发送字典更新通知 + if (status && dictForm.getDictCode() != null) { + webSocketService.broadcastDictChange(dictForm.getDictCode()); + } + return Result.judge(status); + } + + @Operation(summary = "删除字典") + @DeleteMapping("/{ids}") + @PreAuthorize("@ss.hasPerm('sys:dict: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) { + webSocketService.broadcastDictChange(dictCode); + } + + return Result.success(); + } + + + //--------------------------------------------------- + // 字典项相关接口 + //--------------------------------------------------- + @Operation(summary = "字典项分页列表") + @GetMapping("/{dictCode}/items/page") + public PageResult getDictItemPage( + @PathVariable String dictCode, + DictItemPageQuery queryParams + ) { + queryParams.setDictCode(dictCode); + Page result = dictItemService.getDictItemPage(queryParams); + return PageResult.success(result); + } + + @Operation(summary = "字典项列表") + @GetMapping("/{dictCode}/items") + 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:add')") + @RepeatSubmit + public Result saveDictItem( + @PathVariable String dictCode, + @Valid @RequestBody DictItemForm formData + ) { + formData.setDictCode(dictCode); + boolean result = dictItemService.saveDictItem(formData); + + // 发送字典更新通知 + if (result) { + webSocketService.broadcastDictChange(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:edit')") + @RepeatSubmit + 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) { + webSocketService.broadcastDictChange(dictCode); + } + + return Result.judge(status); + } + + @Operation(summary = "删除字典项") + @DeleteMapping("/{dictCode}/items/{itemIds}") + @PreAuthorize("@ss.hasPerm('sys:dict-item:delete')") + public Result deleteDictItems( + @PathVariable String dictCode, + @Parameter(description = "字典ID,多个以英文逗号(,)拼接") @PathVariable String itemIds + ) { + dictItemService.deleteDictItemByIds(itemIds); + + // 发送字典更新通知 + webSocketService.broadcastDictChange(dictCode); + + return Result.success(); + } + +} diff --git a/src/main/java/com/rnb/system/controller/LogController.java b/src/main/java/com/rnb/system/controller/LogController.java new file mode 100644 index 0000000..58769dd --- /dev/null +++ b/src/main/java/com/rnb/system/controller/LogController.java @@ -0,0 +1,61 @@ +package com.rnb.system.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.rnb.common.result.PageResult; +import com.rnb.common.result.Result; +import com.rnb.system.model.query.LogPageQuery; +import com.rnb.system.model.vo.LogPageVO; +import com.rnb.system.model.vo.VisitStatsVO; +import com.rnb.system.model.vo.VisitTrendVO; +import com.rnb.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("/page") + public PageResult getLogPage( + LogPageQuery queryParams + ) { + Page result = logService.getLogPage(queryParams); + return PageResult.success(result); + } + + @Operation(summary = "获取访问趋势") + @GetMapping("/visit-trend") + public Result getVisitTrend( + @Parameter(description = "开始时间", example = "yyyy-MM-dd") @RequestParam String startDate, + @Parameter(description = "结束时间", example = "yyyy-MM-dd") @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("/visit-stats") + public Result getVisitStats() { + VisitStatsVO result = logService.getVisitStats(); + return Result.success(result); + } + +} diff --git a/src/main/java/com/rnb/system/controller/MenuController.java b/src/main/java/com/rnb/system/controller/MenuController.java new file mode 100644 index 0000000..b65d3b1 --- /dev/null +++ b/src/main/java/com/rnb/system/controller/MenuController.java @@ -0,0 +1,113 @@ +package com.rnb.system.controller; + +import com.rnb.common.result.Result; +import com.rnb.common.enums.LogModuleEnum; +import com.rnb.common.annotation.RepeatSubmit; +import com.rnb.system.model.form.MenuForm; +import com.rnb.system.model.query.MenuQuery; +import com.rnb.system.model.vo.MenuVO; +import com.rnb.common.model.Option; +import com.rnb.system.model.vo.RouteVO; +import com.rnb.common.annotation.Log; +import com.rnb.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 = "04.菜单接口") +@RestController +@RequestMapping("/api/v1/menus") +@RequiredArgsConstructor +@Slf4j +public class MenuController { + + private final MenuService menuService; + + @Operation(summary = "菜单列表") + @GetMapping + @Log( value = "菜单列表",module = LogModuleEnum.MENU) + public Result> listMenus(MenuQuery queryParams) { + List menuList = menuService.listMenus(queryParams); + return Result.success(menuList); + } + + @Operation(summary = "菜单下拉列表") + @GetMapping("/options") + public Result>> listMenuOptions( + @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.getCurrentUserRoutes(); + return Result.success(routeList); + } + + @Operation(summary = "菜单表单数据") + @GetMapping("/{id}/form") + 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:add')") + @RepeatSubmit + 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:edit')") + 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')") + public Result deleteMenu( + @Parameter(description = "菜单ID,多个以英文(,)分割") @PathVariable("id") Long id + ) { + boolean result = menuService.deleteMenu(id); + return Result.judge(result); + } + + @Operation(summary = "修改菜单显示状态") + @PatchMapping("/{menuId}") + 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/rnb/system/controller/NoticeController.java b/src/main/java/com/rnb/system/controller/NoticeController.java new file mode 100644 index 0000000..32a192a --- /dev/null +++ b/src/main/java/com/rnb/system/controller/NoticeController.java @@ -0,0 +1,129 @@ +package com.rnb.system.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.rnb.common.result.PageResult; +import com.rnb.common.result.Result; +import com.rnb.system.model.form.NoticeForm; +import com.rnb.system.model.query.NoticePageQuery; +import com.rnb.system.model.vo.NoticeDetailVO; +import com.rnb.system.model.vo.NoticePageVO; +import com.rnb.system.model.vo.UserNoticePageVO; +import com.rnb.system.service.NoticeService; +import com.rnb.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("/page") + @PreAuthorize("@ss.hasPerm('sys:notice:query')") + public PageResult getNoticePage(NoticePageQuery queryParams) { + IPage result = noticeService.getNoticePage(queryParams); + return PageResult.success(result); + } + + @Operation(summary = "新增通知公告") + @PostMapping + @PreAuthorize("@ss.hasPerm('sys:notice:add')") + 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:edit')") + 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:edit')") + 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')") + 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')") + 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')") + 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-page") + public PageResult getMyNoticePage( + NoticePageQuery queryParams + ) { + IPage result = noticeService.getMyNoticePage(queryParams); + return PageResult.success(result); + } +} diff --git a/src/main/java/com/rnb/system/controller/RoleController.java b/src/main/java/com/rnb/system/controller/RoleController.java new file mode 100644 index 0000000..c946b88 --- /dev/null +++ b/src/main/java/com/rnb/system/controller/RoleController.java @@ -0,0 +1,120 @@ +package com.rnb.system.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.rnb.common.enums.LogModuleEnum; +import com.rnb.common.annotation.RepeatSubmit; +import com.rnb.common.model.Option; +import com.rnb.common.result.PageResult; +import com.rnb.common.result.Result; +import com.rnb.system.model.form.RoleForm; +import com.rnb.system.model.query.RolePageQuery; +import com.rnb.system.model.vo.RolePageVO; +import com.rnb.common.annotation.Log; +import com.rnb.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 = "03.角色接口") +@RestController +@RequestMapping("/api/v1/roles") +@RequiredArgsConstructor +public class RoleController { + + private final RoleService roleService; + + @Operation(summary = "角色分页列表") + @GetMapping("/page") + @Log(value = "角色分页列表", module = LogModuleEnum.ROLE) + public PageResult getRolePage( + RolePageQuery 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:add')") + @RepeatSubmit + public Result addRole(@Valid @RequestBody RoleForm roleForm) { + boolean result = roleService.saveRole(roleForm); + return Result.judge(result); + } + + @Operation(summary = "角色表单数据") + @GetMapping("/{roleId}/form") + 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:edit')") + 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')") + public Result deleteRoles( + @Parameter(description = "删除角色,多个以英文逗号(,)拼接") @PathVariable String ids + ) { + roleService.deleteRoles(ids); + return Result.success(); + } + + @Operation(summary = "修改角色状态") + @PutMapping(value = "/{roleId}/status") + 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}/menuIds") + public Result> getRoleMenuIds( + @Parameter(description = "角色ID") @PathVariable Long roleId + ) { + List menuIds = roleService.getRoleMenuIds(roleId); + return Result.success(menuIds); + } + + @Operation(summary = "分配菜单(包括按钮权限)给角色") + @PutMapping("/{roleId}/menus") + public Result assignMenusToRole( + @PathVariable Long roleId, + @RequestBody List menuIds + ) { + roleService.assignMenusToRole(roleId, menuIds); + return Result.success(); + } +} diff --git a/src/main/java/com/rnb/system/controller/UserController.java b/src/main/java/com/rnb/system/controller/UserController.java new file mode 100644 index 0000000..a937c7e --- /dev/null +++ b/src/main/java/com/rnb/system/controller/UserController.java @@ -0,0 +1,257 @@ +package com.rnb.system.controller; + +import cn.idev.excel.EasyExcel; +import cn.idev.excel.ExcelWriter; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.rnb.common.annotation.Log; +import com.rnb.common.annotation.RepeatSubmit; +import com.rnb.common.enums.LogModuleEnum; +import com.rnb.common.model.Option; +import com.rnb.common.result.ExcelResult; +import com.rnb.common.result.PageResult; +import com.rnb.common.result.Result; +import com.rnb.common.util.ExcelUtils; +import com.rnb.core.security.util.SecurityUtils; +import com.rnb.system.listener.UserImportListener; +import com.rnb.system.model.dto.UserExportDTO; +import com.rnb.system.model.dto.UserImportDTO; +import com.rnb.system.model.entity.User; +import com.rnb.system.model.form.*; +import com.rnb.system.model.query.UserPageQuery; +import com.rnb.system.model.dto.CurrentUserDTO; +import com.rnb.system.model.vo.UserPageVO; +import com.rnb.system.model.vo.UserProfileVO; +import com.rnb.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 = "02.用户接口") +@RestController +@RequestMapping("/api/v1/users") +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + @Operation(summary = "用户分页列表") + @GetMapping("/page") + @Log(value = "用户分页列表", module = LogModuleEnum.USER) + public PageResult getUserPage( + @Valid UserPageQuery queryParams + ) { + IPage result = userService.getUserPage(queryParams); + return PageResult.success(result); + } + + @Operation(summary = "新增用户") + @PostMapping + @PreAuthorize("@ss.hasPerm('sys:user:add')") + @RepeatSubmit + @Log(value = "新增用户", module = LogModuleEnum.USER) + public Result saveUser( + @RequestBody @Valid UserForm userForm + ) { + boolean result = userService.saveUser(userForm); + return Result.judge(result); + } + + @Operation(summary = "用户表单数据") + @GetMapping("/{userId}/form") + @Log(value = "用户表单数据", module = LogModuleEnum.USER) + 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:edit')") + @Log(value = "修改用户", module = LogModuleEnum.USER) + 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(value = "删除用户", module = LogModuleEnum.USER) + public Result deleteUsers( + @Parameter(description = "用户ID,多个以英文逗号(,)分割") @PathVariable String ids + ) { + boolean result = userService.deleteUsers(ids); + return Result.judge(result); + } + + @Operation(summary = "修改用户状态") + @PatchMapping(value = "/{userId}/status") + @Log(value = "修改用户状态", module = LogModuleEnum.USER) + public Result updateUserStatus( + @Parameter(description = "用户ID") @PathVariable Long userId, + @Parameter(description = "用户状态(1:启用;0:禁用)") @RequestParam Integer status + ) { + boolean result = userService.update(new LambdaUpdateWrapper() + .eq(User::getId, userId) + .set(User::getStatus, status) + ); + return Result.judge(result); + } + + @Operation(summary = "获取当前登录用户信息") + @GetMapping("/me") + @Log(value = "获取当前登录用户信息", module = LogModuleEnum.USER) + public Result getCurrentUser() { + CurrentUserDTO currentUserDTO = userService.getCurrentUserInfo(); + return Result.success(currentUserDTO); + } + + @Operation(summary = "用户导入模板下载") + @GetMapping("/template") + @Log(value = "用户导入模板下载", module = LogModuleEnum.USER) + 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") + @Log(value = "导入用户", module = LogModuleEnum.USER) + public Result importUsers(MultipartFile file) throws IOException { + UserImportListener listener = new UserImportListener(); + ExcelUtils.importExcel(file.getInputStream(), UserImportDTO.class, listener); + return Result.success(listener.getExcelResult()); + } + + @Operation(summary = "导出用户") + @GetMapping("/export") + @Log(value = "导出用户", module = LogModuleEnum.USER) + public void exportUsers(UserPageQuery 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(), UserExportDTO.class).sheet("用户列表") + .doWrite(exportUserList); + } + + @Operation(summary = "获取个人中心用户信息") + @GetMapping("/profile") + @Log(value = "获取个人中心用户信息", module = LogModuleEnum.USER) + public Result getUserProfile() { + Long userId = SecurityUtils.getUserId(); + UserProfileVO userProfile = userService.getUserProfile(userId); + return Result.success(userProfile); + } + + @Operation(summary = "个人中心修改用户信息") + @PutMapping("/profile") + @Log(value = "个人中心修改用户信息", module = LogModuleEnum.USER) + public Result updateUserProfile(@RequestBody UserProfileForm formData) { + boolean result = userService.updateUserProfile(formData); + return Result.judge(result); + } + + @Operation(summary = "重置用户密码") + @PutMapping(value = "/{userId}/password/reset") + @PreAuthorize("@ss.hasPerm('sys:user:reset-password')") + public Result resetPassword( + @Parameter(description = "用户ID") @PathVariable Long userId, + @RequestParam String password + ) { + boolean result = userService.resetPassword(userId, password); + return Result.judge(result); + } + + @Operation(summary = "修改密码") + @PutMapping(value = "/password") + public Result changePassword( + @RequestBody PasswordUpdateForm data + ) { + Long currUserId = SecurityUtils.getUserId(); + boolean result = userService.changePassword(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 = "发送邮箱验证码(绑定或更换邮箱)") + @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 = "用户下拉选项") + @GetMapping("/options") + public Result>> listUserOptions() { + List> list = userService.listUserOptions(); + return Result.success(list); + } +} diff --git a/src/main/java/com/rnb/system/converter/ConfigConverter.java b/src/main/java/com/rnb/system/converter/ConfigConverter.java new file mode 100644 index 0000000..5ff0fa4 --- /dev/null +++ b/src/main/java/com/rnb/system/converter/ConfigConverter.java @@ -0,0 +1,23 @@ +package com.rnb.system.converter; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.rnb.system.model.entity.Config; +import com.rnb.system.model.vo.ConfigVO; +import com.rnb.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/rnb/system/converter/DeptConverter.java b/src/main/java/com/rnb/system/converter/DeptConverter.java new file mode 100644 index 0000000..0879f7e --- /dev/null +++ b/src/main/java/com/rnb/system/converter/DeptConverter.java @@ -0,0 +1,23 @@ +package com.rnb.system.converter; + +import com.rnb.system.model.entity.Dept; +import com.rnb.system.model.vo.DeptVO; +import com.rnb.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/rnb/system/converter/DictConverter.java b/src/main/java/com/rnb/system/converter/DictConverter.java new file mode 100644 index 0000000..f6e6c02 --- /dev/null +++ b/src/main/java/com/rnb/system/converter/DictConverter.java @@ -0,0 +1,23 @@ +package com.rnb.system.converter; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.rnb.system.model.entity.Dict; +import com.rnb.system.model.vo.DictPageVO; +import com.rnb.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/rnb/system/converter/DictItemConverter.java b/src/main/java/com/rnb/system/converter/DictItemConverter.java new file mode 100644 index 0000000..665a9db --- /dev/null +++ b/src/main/java/com/rnb/system/converter/DictItemConverter.java @@ -0,0 +1,29 @@ +package com.rnb.system.converter; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.rnb.system.model.entity.DictItem; +import com.rnb.system.model.form.DictItemForm; +import com.rnb.system.model.vo.DictPageVO; +import com.rnb.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/rnb/system/converter/MenuConverter.java b/src/main/java/com/rnb/system/converter/MenuConverter.java new file mode 100644 index 0000000..3a5a86a --- /dev/null +++ b/src/main/java/com/rnb/system/converter/MenuConverter.java @@ -0,0 +1,26 @@ +package com.rnb.system.converter; + +import com.rnb.system.model.entity.Menu; +import com.rnb.system.model.vo.MenuVO; +import com.rnb.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/rnb/system/converter/NoticeConverter.java b/src/main/java/com/rnb/system/converter/NoticeConverter.java new file mode 100644 index 0000000..d0b075a --- /dev/null +++ b/src/main/java/com/rnb/system/converter/NoticeConverter.java @@ -0,0 +1,38 @@ +package com.rnb.system.converter; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.rnb.system.model.bo.NoticeBO; +import com.rnb.system.model.entity.Notice; +import com.rnb.system.model.form.NoticeForm; +import com.rnb.system.model.vo.NoticeDetailVO; +import com.rnb.system.model.vo.NoticePageVO; +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); + + NoticePageVO toPageVo(NoticeBO bo); + + Page toPageVo(Page noticePage); + + NoticeDetailVO toDetailVO(NoticeBO noticeBO); +} diff --git a/src/main/java/com/rnb/system/converter/RoleConverter.java b/src/main/java/com/rnb/system/converter/RoleConverter.java new file mode 100644 index 0000000..2f26efa --- /dev/null +++ b/src/main/java/com/rnb/system/converter/RoleConverter.java @@ -0,0 +1,36 @@ +package com.rnb.system.converter; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.rnb.system.model.entity.Role; +import com.rnb.system.model.vo.RolePageVO; +import com.rnb.common.model.Option; +import com.rnb.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 { + + 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); +} \ No newline at end of file diff --git a/src/main/java/com/rnb/system/converter/UserConverter.java b/src/main/java/com/rnb/system/converter/UserConverter.java new file mode 100644 index 0000000..67e77ba --- /dev/null +++ b/src/main/java/com/rnb/system/converter/UserConverter.java @@ -0,0 +1,57 @@ +package com.rnb.system.converter; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.rnb.common.model.Option; +import com.rnb.system.model.entity.User; +import com.rnb.system.model.dto.CurrentUserDTO; +import com.rnb.system.model.vo.UserPageVO; +import com.rnb.system.model.vo.UserProfileVO; +import com.rnb.system.model.bo.UserBO; +import com.rnb.system.model.form.UserForm; +import com.rnb.system.model.dto.UserImportDTO; +import com.rnb.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 { + + UserPageVO toPageVo(UserBO bo); + + Page toPageVo(Page bo); + + UserForm toForm(User entity); + + @InheritInverseConfiguration(name = "toForm") + User toEntity(UserForm entity); + + @Mappings({ + @Mapping(target = "userId", source = "id") + }) + CurrentUserDTO toCurrentUserDto(User entity); + + User toEntity(UserImportDTO vo); + + + UserProfileVO toProfileVo(UserBO bo); + + User toEntity(UserProfileForm formData); + + @Mappings({ + @Mapping(target = "label", source = "nickname"), + @Mapping(target = "value", source = "id") + }) + Option toOption(User entity); + + List> toOptions(List list); +} diff --git a/src/main/java/com/rnb/system/enums/DictCodeEnum.java b/src/main/java/com/rnb/system/enums/DictCodeEnum.java new file mode 100644 index 0000000..58e472a --- /dev/null +++ b/src/main/java/com/rnb/system/enums/DictCodeEnum.java @@ -0,0 +1,28 @@ +package com.rnb.system.enums; + +import com.rnb.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/rnb/system/enums/MenuTypeEnum.java b/src/main/java/com/rnb/system/enums/MenuTypeEnum.java new file mode 100644 index 0000000..b0cdfdf --- /dev/null +++ b/src/main/java/com/rnb/system/enums/MenuTypeEnum.java @@ -0,0 +1,34 @@ +package com.rnb.system.enums; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.rnb.common.base.IBaseEnum; +import lombok.Getter; + +/** + * 菜单类型枚举 + * + * @author Ray.Hao + * @since 2022/4/23 9:36 + */ +@Getter +public enum MenuTypeEnum implements IBaseEnum { + + NULL(0, null), + MENU(1, "菜单"), + CATALOG(2, "目录"), + EXTLINK(3, "外链"), + BUTTON(4, "按钮"); + + // Mybatis-Plus 提供注解表示插入数据库时插入该值 + @EnumValue + private final Integer value; + + // @JsonValue // 表示对枚举序列化时返回此字段 + private final String label; + + MenuTypeEnum(Integer value, String label) { + this.value = value; + this.label = label; + } + +} diff --git a/src/main/java/com/rnb/system/enums/NoticePublishStatusEnum.java b/src/main/java/com/rnb/system/enums/NoticePublishStatusEnum.java new file mode 100644 index 0000000..2a3fed2 --- /dev/null +++ b/src/main/java/com/rnb/system/enums/NoticePublishStatusEnum.java @@ -0,0 +1,30 @@ +package com.rnb.system.enums; + +import com.rnb.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/rnb/system/enums/NoticeTargetEnum.java b/src/main/java/com/rnb/system/enums/NoticeTargetEnum.java new file mode 100644 index 0000000..33f76b2 --- /dev/null +++ b/src/main/java/com/rnb/system/enums/NoticeTargetEnum.java @@ -0,0 +1,29 @@ +package com.rnb.system.enums; + +import com.rnb.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/rnb/system/handler/OnlineUserJobHandler.java b/src/main/java/com/rnb/system/handler/OnlineUserJobHandler.java new file mode 100644 index 0000000..49b121e --- /dev/null +++ b/src/main/java/com/rnb/system/handler/OnlineUserJobHandler.java @@ -0,0 +1,34 @@ +package com.rnb.system.handler; + + +import com.rnb.system.service.UserOnlineService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 在线用户定时任务 + * + * @since 2024/10/7 + * + */ +//@Component +@Slf4j +@RequiredArgsConstructor +public class OnlineUserJobHandler { + + private final UserOnlineService userOnlineService; + private final SimpMessagingTemplate messagingTemplate; + + // 每3分钟统计一次在线用户数,减少服务器压力 +// @Scheduled(cron = "0 */3 * * * ?") + public void execute() { + log.info("定时任务:统计在线用户数"); + // 推送在线用户数量到新主题 + int count = userOnlineService.getOnlineUserCount(); + messagingTemplate.convertAndSend("/topic/online-count", count); + } + +} diff --git a/src/main/java/com/rnb/system/handler/XxlJobSampleHandler.java b/src/main/java/com/rnb/system/handler/XxlJobSampleHandler.java new file mode 100644 index 0000000..6423870 --- /dev/null +++ b/src/main/java/com/rnb/system/handler/XxlJobSampleHandler.java @@ -0,0 +1,19 @@ +package com.rnb.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/rnb/system/listener/UserImportListener.java b/src/main/java/com/rnb/system/listener/UserImportListener.java new file mode 100644 index 0000000..d37a11d --- /dev/null +++ b/src/main/java/com/rnb/system/listener/UserImportListener.java @@ -0,0 +1,220 @@ +package com.rnb.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.rnb.common.constant.SystemConstants; +import com.rnb.common.enums.StatusEnum; +import com.rnb.common.result.ExcelResult; +import com.rnb.system.converter.UserConverter; +import com.rnb.system.enums.DictCodeEnum; +import com.rnb.system.model.dto.UserImportDTO; +import com.rnb.system.model.entity.*; +import com.rnb.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)); + this.deptList = SpringUtil.getBean(DeptService.class) + .list(new LambdaQueryWrapper().select(Dept::getId, Dept::getCode)); + 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(UserImportDTO 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(User::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) { + // 校验通过,持久化至数据库 + User 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) { + this.roleList.stream().filter(r -> r.getCode().equals(roleCode)) + .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)) { + return this.deptList.stream().filter(r -> r.getCode().equals(deptCode)) + .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/rnb/system/mapper/ConfigMapper.java b/src/main/java/com/rnb/system/mapper/ConfigMapper.java new file mode 100644 index 0000000..919a61c --- /dev/null +++ b/src/main/java/com/rnb/system/mapper/ConfigMapper.java @@ -0,0 +1,16 @@ +package com.rnb.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.rnb.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/rnb/system/mapper/DeptMapper.java b/src/main/java/com/rnb/system/mapper/DeptMapper.java new file mode 100644 index 0000000..9fd4dee --- /dev/null +++ b/src/main/java/com/rnb/system/mapper/DeptMapper.java @@ -0,0 +1,20 @@ +package com.rnb.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.rnb.common.annotation.DataPermission; +import com.rnb.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/rnb/system/mapper/DictItemMapper.java b/src/main/java/com/rnb/system/mapper/DictItemMapper.java new file mode 100644 index 0000000..0e92347 --- /dev/null +++ b/src/main/java/com/rnb/system/mapper/DictItemMapper.java @@ -0,0 +1,27 @@ +package com.rnb.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.rnb.system.model.entity.DictItem; +import com.rnb.system.model.query.DictItemPageQuery; +import com.rnb.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, DictItemPageQuery queryParams); +} + + + + diff --git a/src/main/java/com/rnb/system/mapper/DictMapper.java b/src/main/java/com/rnb/system/mapper/DictMapper.java new file mode 100644 index 0000000..167fc7c --- /dev/null +++ b/src/main/java/com/rnb/system/mapper/DictMapper.java @@ -0,0 +1,32 @@ +package com.rnb.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.rnb.system.model.entity.Dict; +import com.rnb.system.model.query.DictPageQuery; +import com.rnb.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, DictPageQuery queryParams); + +} + + + + diff --git a/src/main/java/com/rnb/system/mapper/LogMapper.java b/src/main/java/com/rnb/system/mapper/LogMapper.java new file mode 100644 index 0000000..115baa3 --- /dev/null +++ b/src/main/java/com/rnb/system/mapper/LogMapper.java @@ -0,0 +1,58 @@ +package com.rnb.system.mapper; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.rnb.system.model.bo.VisitCount; +import com.rnb.system.model.bo.VisitStatsBO; +import com.rnb.system.model.entity.Log; +import com.rnb.system.model.query.LogPageQuery; +import com.rnb.system.model.vo.LogPageVO; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + + +/** + * 系统日志数据访问层 + * + * @author Ray + * @since 2.10.0 + */ +@Mapper +public interface LogMapper extends BaseMapper { + + /** + * 获取日志分页列表 + */ + Page getLogPage(Page page, LogPageQuery 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)统计 + */ + VisitStatsBO getPvStats(); + + /** + * 获取访问IP统计 + */ + VisitStatsBO getUvStats(); +} + + + + diff --git a/src/main/java/com/rnb/system/mapper/MenuMapper.java b/src/main/java/com/rnb/system/mapper/MenuMapper.java new file mode 100644 index 0000000..7875049 --- /dev/null +++ b/src/main/java/com/rnb/system/mapper/MenuMapper.java @@ -0,0 +1,27 @@ +package com.rnb.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.rnb.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/rnb/system/mapper/NoticeMapper.java b/src/main/java/com/rnb/system/mapper/NoticeMapper.java new file mode 100644 index 0000000..c1d9ff5 --- /dev/null +++ b/src/main/java/com/rnb/system/mapper/NoticeMapper.java @@ -0,0 +1,37 @@ +package com.rnb.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.rnb.system.model.bo.NoticeBO; +import com.rnb.system.model.entity.Notice; +import com.rnb.system.model.query.NoticePageQuery; +import com.rnb.system.model.vo.NoticePageVO; +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, NoticePageQuery queryParams); + + /** + * 获取阅读时通知公告详情 + * + * @param id 通知公告ID + * @return 通知公告详情 + */ + NoticeBO getNoticeDetail(@Param("id") Long id); +} diff --git a/src/main/java/com/rnb/system/mapper/RoleMapper.java b/src/main/java/com/rnb/system/mapper/RoleMapper.java new file mode 100644 index 0000000..84f8f6d --- /dev/null +++ b/src/main/java/com/rnb/system/mapper/RoleMapper.java @@ -0,0 +1,25 @@ +package com.rnb.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.rnb.system.model.entity.Role; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Set; + +/** + * 角色持久层接口 + * + * @author Ray.Hao + * @since 2022/1/14 + */ +@Mapper +public interface RoleMapper extends BaseMapper { + + /** + * 获取最大范围的数据权限 + * + * @param roles 角色编码集合 + * @return + */ + Integer getMaximumDataScope(Set roles); +} diff --git a/src/main/java/com/rnb/system/mapper/RoleMenuMapper.java b/src/main/java/com/rnb/system/mapper/RoleMenuMapper.java new file mode 100644 index 0000000..807ba4a --- /dev/null +++ b/src/main/java/com/rnb/system/mapper/RoleMenuMapper.java @@ -0,0 +1,41 @@ +package com.rnb.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.rnb.system.model.bo.RolePermsBO; +import com.rnb.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/rnb/system/mapper/UserMapper.java b/src/main/java/com/rnb/system/mapper/UserMapper.java new file mode 100644 index 0000000..6ccfd2a --- /dev/null +++ b/src/main/java/com/rnb/system/mapper/UserMapper.java @@ -0,0 +1,84 @@ +package com.rnb.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.rnb.system.model.bo.UserBO; +import com.rnb.system.model.entity.User; +import com.rnb.system.model.query.UserPageQuery; +import com.rnb.system.model.form.UserForm; +import com.rnb.common.annotation.DataPermission; +import com.rnb.core.security.model.UserAuthCredentials; +import com.rnb.system.model.dto.UserExportDTO; +import org.apache.ibatis.annotations.Mapper; + +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, UserPageQuery queryParams); + + /** + * 获取用户表单详情 + * + * @param userId 用户ID + * @return 用户表单详情 + */ + UserForm getUserFormData(Long userId); + + /** + * 根据用户名获取认证信息 + * + * @param username 用户名 + * @return 认证信息 + */ + UserAuthCredentials getAuthCredentialsByUsername(String username); + + /** + * 根据微信openid获取用户认证信息 + * + * @param openid 微信openid + * @return 认证信息 + */ + UserAuthCredentials getAuthCredentialsByOpenId(String openid); + + /** + * 根据手机号获取用户认证信息 + * + * @param mobile 手机号 + * @return 认证信息 + */ + UserAuthCredentials getAuthCredentialsByMobile(String mobile); + + /** + * 获取导出用户列表 + * + * @param queryParams 查询参数 + * @return 导出用户列表 + */ + @DataPermission(deptAlias = "u", userAlias = "u") + List listExportUsers(UserPageQuery queryParams); + + /** + * 获取用户个人中心信息 + * + * @param userId 用户ID + * @return 用户个人中心信息 + */ + UserBO getUserProfile(Long userId); + +} diff --git a/src/main/java/com/rnb/system/mapper/UserNoticeMapper.java b/src/main/java/com/rnb/system/mapper/UserNoticeMapper.java new file mode 100644 index 0000000..c5ff1c8 --- /dev/null +++ b/src/main/java/com/rnb/system/mapper/UserNoticeMapper.java @@ -0,0 +1,28 @@ +package com.rnb.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.rnb.system.model.entity.UserNotice; +import com.rnb.system.model.query.NoticePageQuery; +import com.rnb.system.model.vo.NoticePageVO; +import com.rnb.system.model.vo.UserNoticePageVO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +/** + * 用户公告状态Mapper接口 + * + * @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") NoticePageQuery queryParams); +} diff --git a/src/main/java/com/rnb/system/mapper/UserRoleMapper.java b/src/main/java/com/rnb/system/mapper/UserRoleMapper.java new file mode 100644 index 0000000..a897098 --- /dev/null +++ b/src/main/java/com/rnb/system/mapper/UserRoleMapper.java @@ -0,0 +1,22 @@ +package com.rnb.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.rnb.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 countUsersForRole(Long roleId); +} diff --git a/src/main/java/com/rnb/system/model/bo/NoticeBO.java b/src/main/java/com/rnb/system/model/bo/NoticeBO.java new file mode 100644 index 0000000..9962b74 --- /dev/null +++ b/src/main/java/com/rnb/system/model/bo/NoticeBO.java @@ -0,0 +1,75 @@ +package com.rnb.system.model.bo; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 通知公告业务对象 + * + * @author Theo + * @since 2024-09-01 10:31 + */ +@Data +public class NoticeBO { + + /** + * 通知ID + */ + private Long id; + + /** + * 通知标题 + */ + private String title; + + /** + * 通知类型 + */ + private Integer type; + + /** + * 通知类型标签 + */ + private String typeLabel; + + /** + * 通知内容 + */ + private String content; + + /** + * 发布人姓名 + */ + private String publisherName; + + /** + * 通知等级(L: 低, M: 中, H: 高) + */ + private String level; + + /** + * 目标类型(1: 全体 2: 指定) + */ + private Integer targetType; + + /** + * 发布状态(0: 未发布, 1: 已发布, -1: 已撤回) + */ + private Integer publishStatus; + + /** + * 创建时间 + */ + private LocalDateTime createTime; + + /** + * 发布时间 + */ + private LocalDateTime publishTime; + + /** + * 撤回时间 + */ + private LocalDateTime revokeTime; +} diff --git a/src/main/java/com/rnb/system/model/bo/RolePermsBO.java b/src/main/java/com/rnb/system/model/bo/RolePermsBO.java new file mode 100644 index 0000000..6a54448 --- /dev/null +++ b/src/main/java/com/rnb/system/model/bo/RolePermsBO.java @@ -0,0 +1,26 @@ +package com.rnb.system.model.bo; + +import lombok.Data; + +import java.util.Set; + +/** + * 角色权限业务对象 + * + * @author haoxr + * @since 2023/11/29 + */ +@Data +public class RolePermsBO { + + /** + * 角色编码 + */ + private String roleCode; + + /** + * 权限标识集合 + */ + private Set perms; + +} diff --git a/src/main/java/com/rnb/system/model/bo/UserBO.java b/src/main/java/com/rnb/system/model/bo/UserBO.java new file mode 100644 index 0000000..a489c25 --- /dev/null +++ b/src/main/java/com/rnb/system/model/bo/UserBO.java @@ -0,0 +1,70 @@ +package com.rnb.system.model.bo; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 用户持久化对象 + * + * @author haoxr + * @since 2022/6/10 + */ +@Data +public class UserBO { + + /** + * 用户ID + */ + private Long id; + + /** + * 账户名 + */ + private String username; + + /** + * 昵称 + */ + private String nickname; + + /** + * 手机号 + */ + private String mobile; + + /** + * 性别(1->男;2->女) + */ + private Integer gender; + + /** + * 头像URL + */ + private String avatar; + + /** + * 邮箱 + */ + private String email; + + /** + * 状态: 1->启用;0->禁用 + */ + private Integer status; + + /** + * 部门名称 + */ + private String deptName; + + /** + * 角色名称,多个使用英文逗号(,)分割 + */ + private String roleNames; + + /** + * 创建时间 + */ + private LocalDateTime createTime; +} diff --git a/src/main/java/com/rnb/system/model/bo/VisitCount.java b/src/main/java/com/rnb/system/model/bo/VisitCount.java new file mode 100644 index 0000000..4c1b827 --- /dev/null +++ b/src/main/java/com/rnb/system/model/bo/VisitCount.java @@ -0,0 +1,23 @@ +package com.rnb.system.model.bo; + +import lombok.Data; + +/** + * 特定日期访问统计 + * + * @author Ray + * @since 2.10.0 + */ +@Data +public class VisitCount { + + /** + * 日期 yyyy-MM-dd + */ + private String date; + + /** + * 访问次数 + */ + private Integer count; +} diff --git a/src/main/java/com/rnb/system/model/bo/VisitStatsBO.java b/src/main/java/com/rnb/system/model/bo/VisitStatsBO.java new file mode 100644 index 0000000..c5cc5f0 --- /dev/null +++ b/src/main/java/com/rnb/system/model/bo/VisitStatsBO.java @@ -0,0 +1,28 @@ +package com.rnb.system.model.bo; + +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 + */ +@Getter +@Setter +public class VisitStatsBO { + + @Schema(description = "今日访问量 (PV)") + private Integer todayCount; + + @Schema(description = "累计访问量 ") + private Integer totalCount; + + @Schema(description = "页面访问量增长率") + private BigDecimal growthRate; + +} diff --git a/src/main/java/com/rnb/system/model/dto/CurrentUserDTO.java b/src/main/java/com/rnb/system/model/dto/CurrentUserDTO.java new file mode 100644 index 0000000..5aba982 --- /dev/null +++ b/src/main/java/com/rnb/system/model/dto/CurrentUserDTO.java @@ -0,0 +1,36 @@ +package com.rnb.system.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.Set; + +/** + * 当前登录用户对象 + * + * @author haoxr + * @since 2022/1/14 + */ +@Schema(description ="当前登录用户对象") +@Data +public class CurrentUserDTO { + + @Schema(description="用户ID") + private Long userId; + + @Schema(description="用户名") + private String username; + + @Schema(description="用户昵称") + private String nickname; + + @Schema(description="头像地址") + private String avatar; + + @Schema(description="用户角色编码集合") + private Set roles; + + @Schema(description="用户权限标识集合") + private Set perms; + +} diff --git a/src/main/java/com/rnb/system/model/dto/NoticeDTO.java b/src/main/java/com/rnb/system/model/dto/NoticeDTO.java new file mode 100644 index 0000000..96b2480 --- /dev/null +++ b/src/main/java/com/rnb/system/model/dto/NoticeDTO.java @@ -0,0 +1,32 @@ +package com.rnb.system.model.dto; + +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 NoticeDTO { + + @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; + + +} diff --git a/src/main/java/com/rnb/system/model/dto/UserExportDTO.java b/src/main/java/com/rnb/system/model/dto/UserExportDTO.java new file mode 100644 index 0000000..23e549d --- /dev/null +++ b/src/main/java/com/rnb/system/model/dto/UserExportDTO.java @@ -0,0 +1,44 @@ +package com.rnb.system.model.dto; + +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 UserExportDTO { + + @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; + + +} diff --git a/src/main/java/com/rnb/system/model/dto/UserImportDTO.java b/src/main/java/com/rnb/system/model/dto/UserImportDTO.java new file mode 100644 index 0000000..21832db --- /dev/null +++ b/src/main/java/com/rnb/system/model/dto/UserImportDTO.java @@ -0,0 +1,36 @@ +package com.rnb.system.model.dto; + +import cn.idev.excel.annotation.ExcelProperty; +import lombok.Data; + +/** + * 用户导入对象 + * + * @author Ray.Hao + * @since 2022/4/10 + */ +@Data +public class UserImportDTO { + + @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; + +} diff --git a/src/main/java/com/rnb/system/model/dto/UserSessionDTO.java b/src/main/java/com/rnb/system/model/dto/UserSessionDTO.java new file mode 100644 index 0000000..0d268ba --- /dev/null +++ b/src/main/java/com/rnb/system/model/dto/UserSessionDTO.java @@ -0,0 +1,37 @@ +package com.rnb.system.model.dto; + +import lombok.Data; + +import java.util.HashSet; +import java.util.Set; + +/** + * 用户会话DTO + * + * @author Ray.Hao + * @since 3.0.0 + */ +@Data +public class UserSessionDTO { + + /** + * 用户名 + */ + private String username; + + /** + * 用户会话ID集合 + */ + private Set sessionIds; + + /** + * 最后活动时间 + */ + private long lastActiveTime; + + public UserSessionDTO(String username) { + this.username = username; + this.sessionIds = new HashSet<>(); + this.lastActiveTime = System.currentTimeMillis(); + } +} diff --git a/src/main/java/com/rnb/system/model/entity/Config.java b/src/main/java/com/rnb/system/model/entity/Config.java new file mode 100644 index 0000000..6688a41 --- /dev/null +++ b/src/main/java/com/rnb/system/model/entity/Config.java @@ -0,0 +1,56 @@ +package com.rnb.system.model.entity; + +import com.baomidou.mybatisplus.annotation.*; +import com.rnb.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/rnb/system/model/entity/Dept.java b/src/main/java/com/rnb/system/model/entity/Dept.java new file mode 100644 index 0000000..27cb6cf --- /dev/null +++ b/src/main/java/com/rnb/system/model/entity/Dept.java @@ -0,0 +1,64 @@ +package com.rnb.system.model.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.rnb.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/rnb/system/model/entity/Dict.java b/src/main/java/com/rnb/system/model/entity/Dict.java new file mode 100644 index 0000000..d5d1cb9 --- /dev/null +++ b/src/main/java/com/rnb/system/model/entity/Dict.java @@ -0,0 +1,45 @@ +package com.rnb.system.model.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.rnb.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/rnb/system/model/entity/DictItem.java b/src/main/java/com/rnb/system/model/entity/DictItem.java new file mode 100644 index 0000000..583bb86 --- /dev/null +++ b/src/main/java/com/rnb/system/model/entity/DictItem.java @@ -0,0 +1,53 @@ +package com.rnb.system.model.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.rnb.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; +} \ No newline at end of file diff --git a/src/main/java/com/rnb/system/model/entity/Log.java b/src/main/java/com/rnb/system/model/entity/Log.java new file mode 100644 index 0000000..3f3d839 --- /dev/null +++ b/src/main/java/com/rnb/system/model/entity/Log.java @@ -0,0 +1,106 @@ +package com.rnb.system.model.entity; + +import com.baomidou.mybatisplus.annotation.*; +import com.rnb.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 Log implements Serializable { + + /** + * 主键 + */ + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 日志模块 + */ + private LogModuleEnum module; + + /** + * 请求方式 + */ + @TableField(value = "request_method") + private String requestMethod; + + /** + * 请求参数 + */ + @TableField(value = "request_params") + private String requestParams; + + /** + * 响应参数 + */ + @TableField(value = "response_content") + private String responseContent; + + /** + * 日志内容 + */ + private String content; + + /** + * 请求路径 + */ + private String requestUri; + + /** + * IP 地址 + */ + private String ip; + + /** + * 省份 + */ + private String province; + + /** + * 城市 + */ + private String city; + + /** + * 浏览器 + */ + private String browser; + + /** + * 浏览器版本 + */ + private String browserVersion; + + /** + * 终端系统 + */ + private String os; + + /** + * 执行时间(毫秒) + */ + private Long executionTime; + + /** + * 创建人ID + */ + private Long createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + +} \ No newline at end of file diff --git a/src/main/java/com/rnb/system/model/entity/Menu.java b/src/main/java/com/rnb/system/model/entity/Menu.java new file mode 100644 index 0000000..7a742ef --- /dev/null +++ b/src/main/java/com/rnb/system/model/entity/Menu.java @@ -0,0 +1,112 @@ +package com.rnb.system.model.entity; + +import com.baomidou.mybatisplus.annotation.*; + +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** + * 菜单实体 + * + * @author Ray.Hao + * @since 2023/3/6 + */ +@TableName("sys_menu") +@Getter +@Setter +public class Menu { + /** + * 菜单ID + */ + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 父菜单ID + */ + private Long parentId; + + /** + * 菜单名称 + */ + private String name; + + /** + * 菜单类型(1-菜单;2-目录;3-外链;4-按钮权限) + */ + private Integer 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) + private String params; + + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + +} \ No newline at end of file diff --git a/src/main/java/com/rnb/system/model/entity/Notice.java b/src/main/java/com/rnb/system/model/entity/Notice.java new file mode 100644 index 0000000..7717aa1 --- /dev/null +++ b/src/main/java/com/rnb/system/model/entity/Notice.java @@ -0,0 +1,87 @@ +package com.rnb.system.model.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.rnb.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/rnb/system/model/entity/Role.java b/src/main/java/com/rnb/system/model/entity/Role.java new file mode 100644 index 0000000..31f2ca8 --- /dev/null +++ b/src/main/java/com/rnb/system/model/entity/Role.java @@ -0,0 +1,58 @@ +package com.rnb.system.model.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.rnb.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/rnb/system/model/entity/RoleMenu.java b/src/main/java/com/rnb/system/model/entity/RoleMenu.java new file mode 100644 index 0000000..5adde23 --- /dev/null +++ b/src/main/java/com/rnb/system/model/entity/RoleMenu.java @@ -0,0 +1,26 @@ +package com.rnb.system.model.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +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/rnb/system/model/entity/User.java b/src/main/java/com/rnb/system/model/entity/User.java new file mode 100644 index 0000000..41394df --- /dev/null +++ b/src/main/java/com/rnb/system/model/entity/User.java @@ -0,0 +1,80 @@ +package com.rnb.system.model.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.rnb.common.base.BaseEntity; +import lombok.Getter; +import lombok.Setter; + +/** + * 用户实体 + */ +@TableName("sys_user") +@Getter +@Setter +public class User 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; + + /** + * 微信 OpenID + */ + private String openid; +} \ No newline at end of file diff --git a/src/main/java/com/rnb/system/model/entity/UserNotice.java b/src/main/java/com/rnb/system/model/entity/UserNotice.java new file mode 100644 index 0000000..e1962a6 --- /dev/null +++ b/src/main/java/com/rnb/system/model/entity/UserNotice.java @@ -0,0 +1,52 @@ +package com.rnb.system.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import com.rnb.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/rnb/system/model/entity/UserRole.java b/src/main/java/com/rnb/system/model/entity/UserRole.java new file mode 100644 index 0000000..dd23216 --- /dev/null +++ b/src/main/java/com/rnb/system/model/entity/UserRole.java @@ -0,0 +1,29 @@ +package com.rnb.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/rnb/system/model/event/DictEvent.java b/src/main/java/com/rnb/system/model/event/DictEvent.java new file mode 100644 index 0000000..2f66a97 --- /dev/null +++ b/src/main/java/com/rnb/system/model/event/DictEvent.java @@ -0,0 +1,27 @@ +package com.rnb.system.model.event; + +import lombok.Data; + +/** + * 字典更新事件 + * + * @author Ray.Hao + * @since 3.0.0 + */ +@Data +public class DictEvent { + /** + * 字典编码 + */ + private String dictCode; + + /** + * 时间戳 + */ + private long timestamp; + + public DictEvent(String dictCode) { + this.dictCode = dictCode; + this.timestamp = System.currentTimeMillis(); + } +} diff --git a/src/main/java/com/rnb/system/model/form/ConfigForm.java b/src/main/java/com/rnb/system/model/form/ConfigForm.java new file mode 100644 index 0000000..084ecd7 --- /dev/null +++ b/src/main/java/com/rnb/system/model/form/ConfigForm.java @@ -0,0 +1,40 @@ +package com.rnb.system.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +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/rnb/system/model/form/DeptForm.java b/src/main/java/com/rnb/system/model/form/DeptForm.java new file mode 100644 index 0000000..83a0983 --- /dev/null +++ b/src/main/java/com/rnb/system/model/form/DeptForm.java @@ -0,0 +1,34 @@ +package com.rnb.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/rnb/system/model/form/DictForm.java b/src/main/java/com/rnb/system/model/form/DictForm.java new file mode 100644 index 0000000..3bce05c --- /dev/null +++ b/src/main/java/com/rnb/system/model/form/DictForm.java @@ -0,0 +1,36 @@ +package com.rnb.system.model.form; + + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import org.hibernate.validator.constraints.Range; + +/** + * 字典表单对象 + * + * @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/rnb/system/model/form/DictItemForm.java b/src/main/java/com/rnb/system/model/form/DictItemForm.java new file mode 100644 index 0000000..35295a6 --- /dev/null +++ b/src/main/java/com/rnb/system/model/form/DictItemForm.java @@ -0,0 +1,38 @@ +package com.rnb.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/rnb/system/model/form/EmailUpdateForm.java b/src/main/java/com/rnb/system/model/form/EmailUpdateForm.java new file mode 100644 index 0000000..13dcb50 --- /dev/null +++ b/src/main/java/com/rnb/system/model/form/EmailUpdateForm.java @@ -0,0 +1,25 @@ +package com.rnb.system.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +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 = "邮箱不能为空") + private String email; + + @Schema(description = "验证码") + @NotBlank(message = "验证码不能为空") + private String code; + +} diff --git a/src/main/java/com/rnb/system/model/form/MenuForm.java b/src/main/java/com/rnb/system/model/form/MenuForm.java new file mode 100644 index 0000000..4d8ca0c --- /dev/null +++ b/src/main/java/com/rnb/system/model/form/MenuForm.java @@ -0,0 +1,66 @@ +package com.rnb.system.model.form; + +import com.rnb.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 = "菜单类型(1-菜单 2-目录 3-外链 4-按钮)") + private Integer 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/rnb/system/model/form/MobileUpdateForm.java b/src/main/java/com/rnb/system/model/form/MobileUpdateForm.java new file mode 100644 index 0000000..a90e848 --- /dev/null +++ b/src/main/java/com/rnb/system/model/form/MobileUpdateForm.java @@ -0,0 +1,25 @@ +package com.rnb.system.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 修改手机表单 + * + * @author Ray.Hao + * @since 2024/8/19 + */ +@Schema(description = "修改手机表单") +@Data +public class MobileUpdateForm { + + @Schema(description = "手机号码") + @NotBlank(message = "手机号码不能为空") + private String mobile; + + @Schema(description = "验证码") + @NotBlank(message = "验证码不能为空") + private String code; + +} diff --git a/src/main/java/com/rnb/system/model/form/NoticeForm.java b/src/main/java/com/rnb/system/model/form/NoticeForm.java new file mode 100644 index 0000000..67e4d63 --- /dev/null +++ b/src/main/java/com/rnb/system/model/form/NoticeForm.java @@ -0,0 +1,54 @@ +package com.rnb.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/rnb/system/model/form/PasswordUpdateForm.java b/src/main/java/com/rnb/system/model/form/PasswordUpdateForm.java new file mode 100644 index 0000000..f38dfff --- /dev/null +++ b/src/main/java/com/rnb/system/model/form/PasswordUpdateForm.java @@ -0,0 +1,25 @@ +package com.rnb.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/rnb/system/model/form/RoleForm.java b/src/main/java/com/rnb/system/model/form/RoleForm.java new file mode 100644 index 0000000..32f0ea2 --- /dev/null +++ b/src/main/java/com/rnb/system/model/form/RoleForm.java @@ -0,0 +1,35 @@ +package com.rnb.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; + +@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="数据权限") + private Integer dataScope; + +} diff --git a/src/main/java/com/rnb/system/model/form/UserForm.java b/src/main/java/com/rnb/system/model/form/UserForm.java new file mode 100644 index 0000000..f426fee --- /dev/null +++ b/src/main/java/com/rnb/system/model/form/UserForm.java @@ -0,0 +1,62 @@ +package com.rnb.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; + + @Schema(description="微信openId") + private String openId; + +} diff --git a/src/main/java/com/rnb/system/model/form/UserProfileForm.java b/src/main/java/com/rnb/system/model/form/UserProfileForm.java new file mode 100644 index 0000000..ea534d6 --- /dev/null +++ b/src/main/java/com/rnb/system/model/form/UserProfileForm.java @@ -0,0 +1,38 @@ +package com.rnb.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 = "用户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; + + +} diff --git a/src/main/java/com/rnb/system/model/query/ConfigPageQuery.java b/src/main/java/com/rnb/system/model/query/ConfigPageQuery.java new file mode 100644 index 0000000..4df23e1 --- /dev/null +++ b/src/main/java/com/rnb/system/model/query/ConfigPageQuery.java @@ -0,0 +1,21 @@ +package com.rnb.system.model.query; + +import com.rnb.common.base.BasePageQuery; +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 ConfigPageQuery extends BasePageQuery { + + @Schema(description="关键字(配置项名称/配置项值)") + private String keywords; +} diff --git a/src/main/java/com/rnb/system/model/query/DeptQuery.java b/src/main/java/com/rnb/system/model/query/DeptQuery.java new file mode 100644 index 0000000..372a26f --- /dev/null +++ b/src/main/java/com/rnb/system/model/query/DeptQuery.java @@ -0,0 +1,22 @@ +package com.rnb.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/rnb/system/model/query/DictItemPageQuery.java b/src/main/java/com/rnb/system/model/query/DictItemPageQuery.java new file mode 100644 index 0000000..60be0f2 --- /dev/null +++ b/src/main/java/com/rnb/system/model/query/DictItemPageQuery.java @@ -0,0 +1,20 @@ +package com.rnb.system.model.query; + + +import com.rnb.common.base.BasePageQuery; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = false) +@Schema(description ="字典项分页查询对象") +public class DictItemPageQuery extends BasePageQuery { + + @Schema(description="关键字(字典项值/字典项名称)") + private String keywords; + + @Schema(description="字典编码") + private String dictCode; + +} diff --git a/src/main/java/com/rnb/system/model/query/DictPageQuery.java b/src/main/java/com/rnb/system/model/query/DictPageQuery.java new file mode 100644 index 0000000..03a425e --- /dev/null +++ b/src/main/java/com/rnb/system/model/query/DictPageQuery.java @@ -0,0 +1,16 @@ +package com.rnb.system.model.query; + +import com.rnb.common.base.BasePageQuery; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = false) +@Schema(description ="字典分页查询对象") +public class DictPageQuery extends BasePageQuery { + + @Schema(description="关键字(字典名称)") + private String keywords; + +} diff --git a/src/main/java/com/rnb/system/model/query/LogPageQuery.java b/src/main/java/com/rnb/system/model/query/LogPageQuery.java new file mode 100644 index 0000000..983d704 --- /dev/null +++ b/src/main/java/com/rnb/system/model/query/LogPageQuery.java @@ -0,0 +1,26 @@ +package com.rnb.system.model.query; + +import com.rnb.common.base.BasePageQuery; +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 LogPageQuery extends BasePageQuery { + + @Schema(description="关键字(日志内容/请求路径/请求方法/地区/浏览器/终端系统)") + private String keywords; + + @Schema(description="操作时间范围") + List createTime; + +} diff --git a/src/main/java/com/rnb/system/model/query/MenuQuery.java b/src/main/java/com/rnb/system/model/query/MenuQuery.java new file mode 100644 index 0000000..2de9f01 --- /dev/null +++ b/src/main/java/com/rnb/system/model/query/MenuQuery.java @@ -0,0 +1,22 @@ +package com.rnb.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/rnb/system/model/query/NoticePageQuery.java b/src/main/java/com/rnb/system/model/query/NoticePageQuery.java new file mode 100644 index 0000000..ebf7601 --- /dev/null +++ b/src/main/java/com/rnb/system/model/query/NoticePageQuery.java @@ -0,0 +1,36 @@ +package com.rnb.system.model.query; + +import com.rnb.common.base.BasePageQuery; +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 NoticePageQuery extends BasePageQuery { + + @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/rnb/system/model/query/RolePageQuery.java b/src/main/java/com/rnb/system/model/query/RolePageQuery.java new file mode 100644 index 0000000..d1ccdb0 --- /dev/null +++ b/src/main/java/com/rnb/system/model/query/RolePageQuery.java @@ -0,0 +1,32 @@ +package com.rnb.system.model.query; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.rnb.common.base.BasePageQuery; +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 RolePageQuery extends BasePageQuery { + + @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/rnb/system/model/query/UserPageQuery.java b/src/main/java/com/rnb/system/model/query/UserPageQuery.java new file mode 100644 index 0000000..b1bec6b --- /dev/null +++ b/src/main/java/com/rnb/system/model/query/UserPageQuery.java @@ -0,0 +1,53 @@ +package com.rnb.system.model.query; + +import cn.hutool.db.sql.Direction; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.rnb.common.base.BasePageQuery; +import com.rnb.common.annotation.ValidField; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +/** + * 用户分页查询对象 + * + * @author haoxr + * @since 2022/1/14 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@Schema(description = "用户分页查询对象") +public class UserPageQuery extends BasePageQuery { + + @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; + + @Schema(description = "排序的字段") + @ValidField(allowedValues = {"create_time", "update_time"}) + private String field; + + @Schema(description = "排序方式(正序:ASC;反序:DESC)") + private Direction direction; + + /** + * 是否超级管理员 + */ + @JsonIgnore + @Schema(hidden = true) + private Boolean isRoot; + +} diff --git a/src/main/java/com/rnb/system/model/vo/ConfigVO.java b/src/main/java/com/rnb/system/model/vo/ConfigVO.java new file mode 100644 index 0000000..d14b540 --- /dev/null +++ b/src/main/java/com/rnb/system/model/vo/ConfigVO.java @@ -0,0 +1,34 @@ +package com.rnb.system.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 系统配置视图对象 + * + * @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; +} diff --git a/src/main/java/com/rnb/system/model/vo/DeptVO.java b/src/main/java/com/rnb/system/model/vo/DeptVO.java new file mode 100644 index 0000000..bc8db8b --- /dev/null +++ b/src/main/java/com/rnb/system/model/vo/DeptVO.java @@ -0,0 +1,42 @@ +package com.rnb.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/rnb/system/model/vo/DictItemOptionVO.java b/src/main/java/com/rnb/system/model/vo/DictItemOptionVO.java new file mode 100644 index 0000000..4ff9ef3 --- /dev/null +++ b/src/main/java/com/rnb/system/model/vo/DictItemOptionVO.java @@ -0,0 +1,27 @@ +package com.rnb.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; + +} diff --git a/src/main/java/com/rnb/system/model/vo/DictItemPageVO.java b/src/main/java/com/rnb/system/model/vo/DictItemPageVO.java new file mode 100644 index 0000000..de9a284 --- /dev/null +++ b/src/main/java/com/rnb/system/model/vo/DictItemPageVO.java @@ -0,0 +1,37 @@ +package com.rnb.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; + +} diff --git a/src/main/java/com/rnb/system/model/vo/DictPageVO.java b/src/main/java/com/rnb/system/model/vo/DictPageVO.java new file mode 100644 index 0000000..44e7fdf --- /dev/null +++ b/src/main/java/com/rnb/system/model/vo/DictPageVO.java @@ -0,0 +1,32 @@ +package com.rnb.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; + +} diff --git a/src/main/java/com/rnb/system/model/vo/LogPageVO.java b/src/main/java/com/rnb/system/model/vo/LogPageVO.java new file mode 100644 index 0000000..737fc61 --- /dev/null +++ b/src/main/java/com/rnb/system/model/vo/LogPageVO.java @@ -0,0 +1,59 @@ +package com.rnb.system.model.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.rnb.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 String content; + + @Schema(description = "请求路径") + private String requestUri; + + @Schema(description = "请求方法") + private String method; + + @Schema(description = "IP 地址") + private String ip; + + @Schema(description = "地区") + private String region; + + @Schema(description = "浏览器") + private String browser; + + @Schema(description = "终端系统") + private String os; + + @Schema(description = "执行时间(毫秒)") + private Long executionTime; + + @Schema(description = "创建人ID") + private Long createBy; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createTime; + + @Schema(description = "操作人") + private String operator; +} \ No newline at end of file diff --git a/src/main/java/com/rnb/system/model/vo/MenuVO.java b/src/main/java/com/rnb/system/model/vo/MenuVO.java new file mode 100644 index 0000000..883befe --- /dev/null +++ b/src/main/java/com/rnb/system/model/vo/MenuVO.java @@ -0,0 +1,53 @@ +package com.rnb.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="菜单类型") + private Integer 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; + +} diff --git a/src/main/java/com/rnb/system/model/vo/NoticeDetailVO.java b/src/main/java/com/rnb/system/model/vo/NoticeDetailVO.java new file mode 100644 index 0000000..dc190d8 --- /dev/null +++ b/src/main/java/com/rnb/system/model/vo/NoticeDetailVO.java @@ -0,0 +1,42 @@ +package com.rnb.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; +} diff --git a/src/main/java/com/rnb/system/model/vo/NoticePageVO.java b/src/main/java/com/rnb/system/model/vo/NoticePageVO.java new file mode 100644 index 0000000..0f68c42 --- /dev/null +++ b/src/main/java/com/rnb/system/model/vo/NoticePageVO.java @@ -0,0 +1,61 @@ +package com.rnb.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; +} diff --git a/src/main/java/com/rnb/system/model/vo/RolePageVO.java b/src/main/java/com/rnb/system/model/vo/RolePageVO.java new file mode 100644 index 0000000..0cc87dd --- /dev/null +++ b/src/main/java/com/rnb/system/model/vo/RolePageVO.java @@ -0,0 +1,33 @@ +package com.rnb.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; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/rnb/system/model/vo/RouteVO.java b/src/main/java/com/rnb/system/model/vo/RouteVO.java new file mode 100644 index 0000000..df0935f --- /dev/null +++ b/src/main/java/com/rnb/system/model/vo/RouteVO.java @@ -0,0 +1,63 @@ +package com.rnb.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; +} diff --git a/src/main/java/com/rnb/system/model/vo/UserNoticePageVO.java b/src/main/java/com/rnb/system/model/vo/UserNoticePageVO.java new file mode 100644 index 0000000..77b4eb2 --- /dev/null +++ b/src/main/java/com/rnb/system/model/vo/UserNoticePageVO.java @@ -0,0 +1,41 @@ +package com.rnb.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; + +} diff --git a/src/main/java/com/rnb/system/model/vo/UserPageVO.java b/src/main/java/com/rnb/system/model/vo/UserPageVO.java new file mode 100644 index 0000000..c0b79ab --- /dev/null +++ b/src/main/java/com/rnb/system/model/vo/UserPageVO.java @@ -0,0 +1,53 @@ +package com.rnb.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; + +} diff --git a/src/main/java/com/rnb/system/model/vo/UserProfileVO.java b/src/main/java/com/rnb/system/model/vo/UserProfileVO.java new file mode 100644 index 0000000..b29c068 --- /dev/null +++ b/src/main/java/com/rnb/system/model/vo/UserProfileVO.java @@ -0,0 +1,50 @@ +package com.rnb.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; + +} diff --git a/src/main/java/com/rnb/system/model/vo/VisitStatsVO.java b/src/main/java/com/rnb/system/model/vo/VisitStatsVO.java new file mode 100644 index 0000000..39a5f6b --- /dev/null +++ b/src/main/java/com/rnb/system/model/vo/VisitStatsVO.java @@ -0,0 +1,38 @@ +package com.rnb.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 VisitStatsVO { + + @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; + +} diff --git a/src/main/java/com/rnb/system/model/vo/VisitTrendVO.java b/src/main/java/com/rnb/system/model/vo/VisitTrendVO.java new file mode 100644 index 0000000..a8dd668 --- /dev/null +++ b/src/main/java/com/rnb/system/model/vo/VisitTrendVO.java @@ -0,0 +1,30 @@ +package com.rnb.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 = "IP数") + private List ipList; + +} diff --git a/src/main/java/com/rnb/system/service/ConfigService.java b/src/main/java/com/rnb/system/service/ConfigService.java new file mode 100644 index 0000000..9c5f927 --- /dev/null +++ b/src/main/java/com/rnb/system/service/ConfigService.java @@ -0,0 +1,75 @@ +package com.rnb.system.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.IService; +import com.rnb.system.model.entity.Config; +import com.rnb.system.model.form.ConfigForm; +import com.rnb.system.model.query.ConfigPageQuery; +import com.rnb.system.model.vo.ConfigVO; + +/** + * 系统配置Service接口 + * + * @author Theo + * @since 2024-07-29 11:17:26 + */ +public interface ConfigService extends IService { + + /** + * 分页查询系统配置 + * @param sysConfigPageQuery 查询参数 + * @return 系统配置分页列表 + */ + IPage page(ConfigPageQuery 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); + + /** + * 更新系统配置 + * @param key 配置键 + * @param val 配置值 + * @return 是否成功 + */ + boolean updateSystemConfig(String key, Object val); +} diff --git a/src/main/java/com/rnb/system/service/DeptService.java b/src/main/java/com/rnb/system/service/DeptService.java new file mode 100644 index 0000000..2ae268e --- /dev/null +++ b/src/main/java/com/rnb/system/service/DeptService.java @@ -0,0 +1,65 @@ +package com.rnb.system.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.rnb.system.model.entity.Dept; +import com.rnb.common.model.Option; +import com.rnb.system.model.form.DeptForm; +import com.rnb.system.model.query.DeptQuery; +import com.rnb.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/rnb/system/service/DictItemService.java b/src/main/java/com/rnb/system/service/DictItemService.java new file mode 100644 index 0000000..7d8eb62 --- /dev/null +++ b/src/main/java/com/rnb/system/service/DictItemService.java @@ -0,0 +1,68 @@ +package com.rnb.system.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.IService; +import com.rnb.system.model.entity.DictItem; +import com.rnb.system.model.form.DictItemForm; +import com.rnb.system.model.query.DictItemPageQuery; +import com.rnb.system.model.vo.DictItemOptionVO; +import com.rnb.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(DictItemPageQuery 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/rnb/system/service/DictService.java b/src/main/java/com/rnb/system/service/DictService.java new file mode 100644 index 0000000..17ba5fd --- /dev/null +++ b/src/main/java/com/rnb/system/service/DictService.java @@ -0,0 +1,75 @@ +package com.rnb.system.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.IService; +import com.rnb.common.model.Option; +import com.rnb.system.model.entity.Dict; +import com.rnb.system.model.form.DictForm; +import com.rnb.system.model.query.DictPageQuery; +import com.rnb.system.model.vo.DictPageVO; + +import java.util.List; + +/** + * 字典业务接口 + * + * @author haoxr + * @since 2022/10/12 + */ +public interface DictService extends IService { + + /** + * 获取字典分页列表 + * + * @param queryParams 分页查询对象 + * @return 字典分页列表 + */ + Page getDictPage(DictPageQuery 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/rnb/system/service/LogService.java b/src/main/java/com/rnb/system/service/LogService.java new file mode 100644 index 0000000..16034fc --- /dev/null +++ b/src/main/java/com/rnb/system/service/LogService.java @@ -0,0 +1,40 @@ +package com.rnb.system.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.rnb.system.model.entity.Log; +import com.baomidou.mybatisplus.extension.service.IService; +import com.rnb.system.model.query.LogPageQuery; +import com.rnb.system.model.vo.LogPageVO; +import com.rnb.system.model.vo.VisitStatsVO; +import com.rnb.system.model.vo.VisitTrendVO; + +import java.time.LocalDate; + +/** + * 系统日志 服务接口 + * + * @author Ray.Hao + * @since 2.10.0 + */ +public interface LogService extends IService { + + /** + * 获取日志分页列表 + */ + Page getLogPage(LogPageQuery queryParams); + + + /** + * 获取访问趋势 + * + * @param startDate 开始时间 + * @param endDate 结束时间 + */ + VisitTrendVO getVisitTrend(LocalDate startDate, LocalDate endDate); + + /** + * 获取访问统计 + */ + VisitStatsVO getVisitStats(); + +} diff --git a/src/main/java/com/rnb/system/service/MenuService.java b/src/main/java/com/rnb/system/service/MenuService.java new file mode 100644 index 0000000..1f33ebe --- /dev/null +++ b/src/main/java/com/rnb/system/service/MenuService.java @@ -0,0 +1,75 @@ +package com.rnb.system.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.rnb.shared.codegen.model.entity.GenConfig; +import com.rnb.system.model.form.MenuForm; +import com.rnb.common.model.Option; +import com.rnb.system.model.entity.Menu; +import com.rnb.system.model.query.MenuQuery; +import com.rnb.system.model.vo.MenuVO; +import com.rnb.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 getCurrentUserRoutes(); + + /** + * 修改菜单显示状态 + * + * @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, GenConfig genConfig); +} diff --git a/src/main/java/com/rnb/system/service/NoticeService.java b/src/main/java/com/rnb/system/service/NoticeService.java new file mode 100644 index 0000000..7d9f8c3 --- /dev/null +++ b/src/main/java/com/rnb/system/service/NoticeService.java @@ -0,0 +1,91 @@ +package com.rnb.system.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.IService; +import com.rnb.system.model.entity.Notice; +import com.rnb.system.model.form.NoticeForm; +import com.rnb.system.model.query.NoticePageQuery; +import com.rnb.system.model.vo.NoticePageVO; +import com.rnb.system.model.vo.UserNoticePageVO; +import com.rnb.system.model.vo.NoticeDetailVO; + +/** + * 通知公告服务类 + * + * @author youlaitech + * @since 2024-08-27 10:31 + */ +public interface NoticeService extends IService { + + /** + * 通知公告分页列表 + * + * @return 通知公告分页列表 + */ + IPage getNoticePage(NoticePageQuery 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(NoticePageQuery queryParams); +} diff --git a/src/main/java/com/rnb/system/service/RoleMenuService.java b/src/main/java/com/rnb/system/service/RoleMenuService.java new file mode 100644 index 0000000..28ab746 --- /dev/null +++ b/src/main/java/com/rnb/system/service/RoleMenuService.java @@ -0,0 +1,54 @@ +package com.rnb.system.service; + + +import com.baomidou.mybatisplus.extension.service.IService; +import com.rnb.system.model.entity.RoleMenu; + +import java.util.List; +import java.util.Set; + +/** + * 角色菜单业务接口 + * + * @author haoxr + * @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); + + /** + * 获取角色权限集合 + * + * @param roles 角色编码集合 + * @return 权限集合 + */ + Set getRolePermsByRoleCodes(Set roles); +} diff --git a/src/main/java/com/rnb/system/service/RoleService.java b/src/main/java/com/rnb/system/service/RoleService.java new file mode 100644 index 0000000..bc40216 --- /dev/null +++ b/src/main/java/com/rnb/system/service/RoleService.java @@ -0,0 +1,95 @@ +package com.rnb.system.service; + + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.IService; +import com.rnb.system.model.entity.Role; +import com.rnb.common.model.Option; +import com.rnb.system.model.form.RoleForm; +import com.rnb.system.model.query.RolePageQuery; +import com.rnb.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(RolePageQuery 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); + + +} diff --git a/src/main/java/com/rnb/system/service/UserNoticeService.java b/src/main/java/com/rnb/system/service/UserNoticeService.java new file mode 100644 index 0000000..a3ab6ba --- /dev/null +++ b/src/main/java/com/rnb/system/service/UserNoticeService.java @@ -0,0 +1,33 @@ +package com.rnb.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.rnb.system.model.entity.UserNotice; +import com.rnb.system.model.query.NoticePageQuery; +import com.rnb.system.model.vo.UserNoticePageVO; +import com.rnb.system.model.vo.NoticePageVO; + +/** + * 用户公告状态服务类 + * + * @author youlaitech + * @since 2024-08-28 16:56 + */ +public interface UserNoticeService extends IService { + + /** + * 全部标记为已读 + * + * @return 是否成功 + */ + boolean readAll(); + + /** + * 分页获取我的通知公告 + * @param page 分页对象 + * @param queryParams 查询参数 + * @return 我的通知公告分页列表 + */ + IPage getMyNoticePage(Page page, NoticePageQuery queryParams); +} diff --git a/src/main/java/com/rnb/system/service/UserOnlineService.java b/src/main/java/com/rnb/system/service/UserOnlineService.java new file mode 100644 index 0000000..4ad7150 --- /dev/null +++ b/src/main/java/com/rnb/system/service/UserOnlineService.java @@ -0,0 +1,163 @@ +package com.rnb.system.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +/** + * 用户在线状态服务 + * 负责维护用户的在线状态和相关统计 + * + * @author Ray.Hao + * @since 3.0.0 + */ +@Service +@Slf4j +public class UserOnlineService { + + // 在线用户映射表,key为用户名,value为用户在线信息 + private final Map onlineUsers = new ConcurrentHashMap<>(); + + private SimpMessagingTemplate messagingTemplate; + private final ObjectMapper objectMapper; + + @Autowired + public UserOnlineService(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Autowired(required = false) + public void setMessagingTemplate(SimpMessagingTemplate messagingTemplate) { + this.messagingTemplate = messagingTemplate; + } + + /** + * 用户上线 + * + * @param username 用户名 + * @param sessionId WebSocket会话ID(可选) + */ + public void userConnected(String username, String sessionId) { + // 生成会话ID(如果未提供) + String actualSessionId = sessionId != null ? sessionId : "session-" + System.nanoTime(); + UserOnlineInfo info = new UserOnlineInfo(username, actualSessionId, System.currentTimeMillis()); + onlineUsers.put(username, info); + log.info("用户[{}]上线,当前在线用户数:{}", username, onlineUsers.size()); + + // 通知在线用户状态变更 + notifyOnlineUsersChange(); + } + + /** + * 用户下线 + * + * @param username 用户名 + */ + public void userDisconnected(String username) { + onlineUsers.remove(username); + log.info("用户[{}]下线,当前在线用户数:{}", username, onlineUsers.size()); + + // 通知在线用户状态变更 + notifyOnlineUsersChange(); + } + + /** + * 获取在线用户列表 + * + * @return 在线用户名列表 + */ + public List getOnlineUsers() { + return onlineUsers.values().stream() + .map(info -> new UserOnlineDTO(info.getUsername(), info.getLoginTime())) + .collect(Collectors.toList()); + } + + /** + * 获取在线用户数量 + * + * @return 在线用户数 + */ + public int getOnlineUserCount() { + return onlineUsers.size(); + } + + /** + * 检查用户是否在线 + * + * @param username 用户名 + * @return 是否在线 + */ + public boolean isUserOnline(String username) { + return onlineUsers.containsKey(username); + } + + /** + * 通知所有客户端在线用户变更 + */ + private void notifyOnlineUsersChange() { + if (messagingTemplate == null) { + log.warn("消息模板尚未初始化,无法发送在线用户数量"); + return; + } + + // 发送简化版数据(仅数量) + sendOnlineUserCount(); + } + + /** + * 发送在线用户数量(简化版,不包含用户详情) + */ + private void sendOnlineUserCount() { + if (messagingTemplate == null) { + log.warn("消息模板尚未初始化,无法发送在线用户数量"); + return; + } + + try { + // 直接发送数量,更轻量 + int count = onlineUsers.size(); + messagingTemplate.convertAndSend("/topic/online-count", count); + log.debug("已发送在线用户数量: {}", count); + } catch (Exception e) { + log.error("发送在线用户数量失败", e); + } + } + + /** + * 用户在线信息 + */ + @Data + private static class UserOnlineInfo { + private final String username; + private final String sessionId; + private final long loginTime; + } + + /** + * 用户在线DTO(用于返回给前端) + */ + @Data + public static class UserOnlineDTO { + private final String username; + private final long loginTime; + } + + /** + * 在线用户变更事件 + */ + @Data + private static class OnlineUsersChangeEvent { + private String type; + private int count; + private List users; + private long timestamp; + } +} diff --git a/src/main/java/com/rnb/system/service/UserRoleService.java b/src/main/java/com/rnb/system/service/UserRoleService.java new file mode 100644 index 0000000..78d9dcc --- /dev/null +++ b/src/main/java/com/rnb/system/service/UserRoleService.java @@ -0,0 +1,27 @@ +package com.rnb.system.service; + + +import com.baomidou.mybatisplus.extension.service.IService; +import com.rnb.system.model.entity.UserRole; + +import java.util.List; + +public interface UserRoleService extends IService { + + /** + * 保存用户角色 + * + * @param userId + * @param roleIds + * @return + */ + boolean saveUserRoles(Long userId, List roleIds); + + /** + * 判断角色是否存在绑定的用户 + * + * @param roleId 角色ID + * @return true:已分配 false:未分配 + */ + boolean hasAssignedUsers(Long roleId); +} diff --git a/src/main/java/com/rnb/system/service/UserService.java b/src/main/java/com/rnb/system/service/UserService.java new file mode 100644 index 0000000..7c651c0 --- /dev/null +++ b/src/main/java/com/rnb/system/service/UserService.java @@ -0,0 +1,190 @@ +package com.rnb.system.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.IService; +import com.rnb.common.model.Option; +import com.rnb.core.security.model.UserAuthCredentials; +import com.rnb.system.model.dto.CurrentUserDTO; +import com.rnb.system.model.dto.UserExportDTO; +import com.rnb.system.model.entity.User; +import com.rnb.system.model.form.*; +import com.rnb.system.model.query.UserPageQuery; +import com.rnb.system.model.vo.UserPageVO; +import com.rnb.system.model.vo.UserProfileVO; + +import java.util.List; + +/** + * 用户业务接口 + * + * @author Ray.Hao + * @since 2022/1/14 + */ +public interface UserService extends IService { + + /** + * 用户分页列表 + * + * @return {@link IPage} 用户分页列表 + */ + IPage getUserPage(UserPageQuery 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 UserAuthCredentials} + */ + + UserAuthCredentials getAuthCredentialsByUsername(String username); + + + /** + * 获取导出用户列表 + * + * @param queryParams 查询参数 + * @return {@link List} 导出用户列表 + */ + List listExportUsers(UserPageQuery queryParams); + + + /** + * 获取登录用户信息 + * + * @return {@link CurrentUserDTO} 登录用户信息 + */ + CurrentUserDTO 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 changePassword(Long userId, PasswordUpdateForm data); + + /** + * 重置用户密码 + * + * @param userId 用户ID + * @param password 重置后的密码 + * @return {@link Boolean} 是否重置成功 + */ + boolean resetPassword(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); + + /** + * 获取用户选项列表 + * + * @return {@link List>} 用户选项列表 + */ + List> listUserOptions(); + + /** + * 根据 openid 获取用户认证信息 + * + * @param username 用户名 + * @return {@link UserAuthCredentials} + */ + + UserAuthCredentials getAuthCredentialsByOpenId(String username); + + /** + * 根据微信 OpenID 注册或绑定用户 + * + * @param openId 微信 OpenID + */ + void registerOrBindWechatUser(String openId); + + /** + * 根据手机号获取用户认证信息 + * + * @param mobile 手机号 + * @return {@link UserAuthCredentials} + */ + UserAuthCredentials getAuthCredentialsByMobile(String mobile); + + +} diff --git a/src/main/java/com/rnb/system/service/WebSocketService.java b/src/main/java/com/rnb/system/service/WebSocketService.java new file mode 100644 index 0000000..fcfaed2 --- /dev/null +++ b/src/main/java/com/rnb/system/service/WebSocketService.java @@ -0,0 +1,46 @@ +package com.rnb.system.service; + +/** + * WebSocket服务接口 + *

+ * 提供与WebSocket连接管理相关的功能,包括: + * - 用户连接/断开事件处理 + * - 字典数据变更通知 + * - 系统消息推送 + *

+ * + * @author Ray.Hao + * @since 3.0.0 + */ +public interface WebSocketService { + + /** + * 处理用户连接事件 + * + * @param username 用户名 + * @param sessionId WebSocket会话ID + */ + void userConnected(String username, String sessionId); + + /** + * 处理用户断开连接事件 + * + * @param username 用户名 + */ + void userDisconnected(String username); + + /** + * 广播字典数据变更通知 + * + * @param dictCode 字典编码 + */ + void broadcastDictChange(String dictCode); + + /** + * 发送系统通知给特定用户 + * + * @param username 目标用户名 + * @param message 通知消息内容 + */ + void sendNotification(String username, Object message); +} diff --git a/src/main/java/com/rnb/system/service/impl/ConfigServiceImpl.java b/src/main/java/com/rnb/system/service/impl/ConfigServiceImpl.java new file mode 100644 index 0000000..9e5b3ca --- /dev/null +++ b/src/main/java/com/rnb/system/service/impl/ConfigServiceImpl.java @@ -0,0 +1,171 @@ +package com.rnb.system.service.impl; + +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.rnb.common.constant.RedisConstants; +import com.rnb.system.converter.ConfigConverter; +import com.rnb.system.mapper.ConfigMapper; +import com.rnb.system.model.entity.Config; +import com.rnb.system.model.form.ConfigForm; +import com.rnb.system.model.query.ConfigPageQuery; +import com.rnb.system.model.vo.ConfigVO; +import com.rnb.system.service.ConfigService; +import com.rnb.core.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 configPageQuery 查询参数 + * @return 系统配置分页列表 + */ + @Override + public IPage page(ConfigPageQuery configPageQuery) { + Page page = new Page<>(configPageQuery.getPageNum(), configPageQuery.getPageSize()); + String keywords = configPageQuery.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()); + config.setIsDeleted(0); + 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.update(new LambdaUpdateWrapper() + .eq(Config::getId,id) + .set(Config::getIsDeleted, 1) + .set(Config::getUpdateBy, SecurityUtils.getUserId()) + ); + } + 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; + } + + public boolean updateSystemConfig(String key, Object val){ + redisTemplate.opsForHash().put(RedisConstants.System.CONFIG, key, val); + return super.update(new LambdaUpdateWrapper() + .eq(Config::getConfigKey,key) + .set(Config::getConfigValue, val)); + } +} diff --git a/src/main/java/com/rnb/system/service/impl/DeptServiceImpl.java b/src/main/java/com/rnb/system/service/impl/DeptServiceImpl.java new file mode 100644 index 0000000..2825276 --- /dev/null +++ b/src/main/java/com/rnb/system/service/impl/DeptServiceImpl.java @@ -0,0 +1,272 @@ +package com.rnb.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.rnb.core.security.util.SecurityUtils; +import com.rnb.system.converter.DeptConverter; +import com.rnb.system.mapper.DeptMapper; +import com.rnb.system.model.entity.Dept; +import com.rnb.system.model.form.DeptForm; +import com.rnb.system.model.query.DeptQuery; +import com.rnb.system.model.vo.DeptVO; +import com.rnb.common.constant.SystemConstants; +import com.rnb.common.enums.StatusEnum; +import com.rnb.common.model.Option; +import com.rnb.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.EMPTY_LIST; + } + + 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) { + this.update(new LambdaUpdateWrapper() + .eq(Dept::getId, deptId) + .or() + .apply("CONCAT (',',tree_path,',') LIKE CONCAT('%,',{0},',%')", deptId) + .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/rnb/system/service/impl/DictItemServiceImpl.java b/src/main/java/com/rnb/system/service/impl/DictItemServiceImpl.java new file mode 100644 index 0000000..18f886a --- /dev/null +++ b/src/main/java/com/rnb/system/service/impl/DictItemServiceImpl.java @@ -0,0 +1,125 @@ +package com.rnb.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.rnb.system.converter.DictItemConverter; +import com.rnb.system.mapper.DictItemMapper; +import com.rnb.system.model.entity.DictItem; +import com.rnb.system.model.form.DictItemForm; +import com.rnb.system.model.query.DictItemPageQuery; +import com.rnb.system.model.vo.DictItemOptionVO; +import com.rnb.system.model.vo.DictItemPageVO; +import com.rnb.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(DictItemPageQuery 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/rnb/system/service/impl/DictServiceImpl.java b/src/main/java/com/rnb/system/service/impl/DictServiceImpl.java new file mode 100644 index 0000000..f205fd1 --- /dev/null +++ b/src/main/java/com/rnb/system/service/impl/DictServiceImpl.java @@ -0,0 +1,168 @@ +package com.rnb.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.rnb.common.exception.BusinessException; +import com.rnb.common.model.Option; +import com.rnb.system.converter.DictConverter; +import com.rnb.system.mapper.DictMapper; +import com.rnb.system.model.entity.Dict; +import com.rnb.system.model.entity.DictItem; +import com.rnb.system.model.form.DictForm; +import com.rnb.system.model.query.DictPageQuery; +import com.rnb.system.model.vo.DictPageVO; +import com.rnb.system.service.DictItemService; +import com.rnb.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(DictPageQuery 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 + 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); + return this.updateById(dict); + } + + /** + * 删除字典 + * + * @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/rnb/system/service/impl/LogServiceImpl.java b/src/main/java/com/rnb/system/service/impl/LogServiceImpl.java new file mode 100644 index 0000000..73740e7 --- /dev/null +++ b/src/main/java/com/rnb/system/service/impl/LogServiceImpl.java @@ -0,0 +1,116 @@ +package com.rnb.system.service.impl; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.rnb.system.mapper.LogMapper; +import com.rnb.system.model.bo.VisitCount; +import com.rnb.system.model.bo.VisitStatsBO; +import com.rnb.system.model.entity.Log; +import com.rnb.system.model.query.LogPageQuery; +import com.rnb.system.model.vo.LogPageVO; +import com.rnb.system.model.vo.VisitStatsVO; +import com.rnb.system.model.vo.VisitTrendVO; +import com.rnb.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(LogPageQuery 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(VisitCount::getDate, VisitCount::getCount)); + Map ipMap = ipCounts.stream().collect(Collectors.toMap(VisitCount::getDate, VisitCount::getCount)); + + // 匹配日期和访问量/访问 IP 数 + List pvList = new ArrayList<>(); + List ipList = new ArrayList<>(); + + for (String date : dates) { + pvList.add(pvMap.getOrDefault(date, 0)); + ipList.add(ipMap.getOrDefault(date, 0)); + } + + visitTrend.setPvList(pvList); + visitTrend.setIpList(ipList); + + return visitTrend; + } + + /** + * 访问量统计 + */ + @Override + public VisitStatsVO getVisitStats() { + VisitStatsVO result = new VisitStatsVO(); + + // 访客数统计(UV) + VisitStatsBO uvStats = this.baseMapper.getUvStats(); + if(uvStats!=null){ + result.setTodayUvCount(uvStats.getTodayCount()); + result.setTotalUvCount(uvStats.getTotalCount()); + result.setUvGrowthRate(uvStats.getGrowthRate()); + } + + // 浏览量统计(PV) + VisitStatsBO pvStats = this.baseMapper.getPvStats(); + if(pvStats!=null){ + result.setTodayPvCount(pvStats.getTodayCount()); + result.setTotalPvCount(pvStats.getTotalCount()); + result.setPvGrowthRate(pvStats.getGrowthRate()); + } + + return result; + } + +} + + + + diff --git a/src/main/java/com/rnb/system/service/impl/MenuServiceImpl.java b/src/main/java/com/rnb/system/service/impl/MenuServiceImpl.java new file mode 100644 index 0000000..a412b1d --- /dev/null +++ b/src/main/java/com/rnb/system/service/impl/MenuServiceImpl.java @@ -0,0 +1,471 @@ +package com.rnb.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 cn.hutool.json.JSONUtil; +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.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.rnb.core.security.util.SecurityUtils; +import com.rnb.system.converter.MenuConverter; +import com.rnb.system.mapper.MenuMapper; +import com.rnb.shared.codegen.model.entity.GenConfig; +import com.rnb.system.model.entity.Menu; +import com.rnb.system.model.form.MenuForm; +import com.rnb.system.model.query.MenuQuery; +import com.rnb.system.model.vo.MenuVO; +import com.rnb.system.model.vo.RouteVO; +import com.rnb.common.constant.SystemConstants; +import com.rnb.system.enums.MenuTypeEnum; +import com.rnb.common.enums.StatusEnum; +import com.rnb.common.model.KeyValue; +import com.rnb.common.model.Option; +import com.rnb.system.service.MenuService; +import com.rnb.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 getCurrentUserRoutes() { + + 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); + } + return buildRoutes(SystemConstants.ROOT_NODE_ID, menuList); + } + + /** + * 递归生成菜单路由层级列表 + * + * @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 routeName = menu.getRouteName(); + if (StrUtil.isBlank(routeName)) { + // 路由 name 需要驼峰,首字母大写 + routeName = StringUtils.capitalize(StrUtil.toCamelCase(menu.getRoutePath(), '-')); + } + // 根据name路由跳转 this.$router.push({name:xxx}) + routeVO.setName(routeName); + + // 根据path路由跳转 this.$router.push({path:xxx}) + routeVO.setPath(menu.getRoutePath()); + routeVO.setRedirect(menu.getRedirect()); + routeVO.setComponent(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)); + + String paramsJson = menu.getParams(); + // 将 JSON 字符串转换为 Map + if (StrUtil.isNotBlank(paramsJson)) { + ObjectMapper objectMapper = new ObjectMapper(); + try { + Map paramMap = objectMapper.readValue(paramsJson, new TypeReference<>() { + }); + meta.setParams(paramMap); + } catch (Exception e) { + throw new RuntimeException("解析参数失败", e); + } + } + routeVO.setMeta(meta); + return routeVO; + } + + /** + * 新增/修改菜单 + */ + @Override + @CacheEvict(cacheNames = "menu", key = "'routes'") + public boolean saveMenu(MenuForm menuForm) { + + Integer menuType = menuForm.getType(); + + if (MenuTypeEnum.CATALOG.getValue().equals(menuType)) { // 如果是目录 + String path = menuForm.getRoutePath(); + if (menuForm.getParentId() == 0 && !path.startsWith("/")) { + menuForm.setRoutePath("/" + path); // 一级目录需以 / 开头 + } + menuForm.setComponent("Layout"); + } else if (MenuTypeEnum.EXTLINK.getValue().equals(menuType)) { + // 外链菜单组件设置为 null + 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(); + // 路由参数 [{key:"id",value:"1"},{key:"name",value:"张三"}] 转换为 [{"id":"1"},{"name":"张三"}] + if (CollectionUtil.isNotEmpty(params)) { + entity.setParams(JSONUtil.toJsonStr(params.stream() + .collect(Collectors.toMap(KeyValue::getKey, KeyValue::getValue)))); + } else { + entity.setParams(null); + } + // 新增类型为菜单时候 路由名称唯一 + if (MenuTypeEnum.MENU.getValue().equals(menuType)) { + 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); + // 路由参数字符串 {"id":"1","name":"张三"} 转换为 [{key:"id", value:"1"}, {key:"name", value:"张三"}] + String params = entity.getParams(); + if (StrUtil.isNotBlank(params)) { + ObjectMapper objectMapper = new ObjectMapper(); + try { + // 解析 JSON 字符串为 Map + Map paramMap = objectMapper.readValue(params, new TypeReference<>() { + }); + + // 转换为 List 格式 [{key:"id", value:"1"}, {key:"name", value:"张三"}] + List transformedList = paramMap.entrySet().stream() + .map(entry -> new KeyValue(entry.getKey(), entry.getValue())) + .toList(); + + // 将转换后的列表存入 MenuForm + formData.setParams(transformedList); + } catch (Exception e) { + throw new RuntimeException("解析参数失败", e); + } + } + + 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 genConfig 实体名称 + */ + @Override + public void addMenuForCodegen(Long parentMenuId, GenConfig genConfig) { + Menu parentMenu = this.getById(parentMenuId); + Assert.notNull(parentMenu, "上级菜单不存在"); + + String entityName = genConfig.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(genConfig.getBusinessName()); + + menu.setRouteName(entityName); + menu.setRoutePath(StrUtil.toSymbolCase(entityName, '-')); + menu.setComponent(genConfig.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 = genConfig.getModuleName() + ":" + StrUtil.lowerFirst(entityName) + ":"; + String[] actions = {"查询", "新增", "编辑", "删除"}; + String[] perms = {"query", "add", "edit", "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/rnb/system/service/impl/NoticeServiceImpl.java b/src/main/java/com/rnb/system/service/impl/NoticeServiceImpl.java new file mode 100644 index 0000000..6c1b9f3 --- /dev/null +++ b/src/main/java/com/rnb/system/service/impl/NoticeServiceImpl.java @@ -0,0 +1,301 @@ +package com.rnb.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.rnb.common.exception.BusinessException; +import com.rnb.core.security.util.SecurityUtils; +import com.rnb.system.converter.NoticeConverter; +import com.rnb.system.enums.NoticePublishStatusEnum; +import com.rnb.system.enums.NoticeTargetEnum; +import com.rnb.system.mapper.NoticeMapper; +import com.rnb.system.model.bo.NoticeBO; +import com.rnb.system.model.dto.NoticeDTO; +import com.rnb.system.model.entity.Notice; +import com.rnb.system.model.entity.UserNotice; +import com.rnb.system.model.entity.User; +import com.rnb.system.model.form.NoticeForm; +import com.rnb.system.model.query.NoticePageQuery; +import com.rnb.system.model.vo.NoticePageVO; +import com.rnb.system.model.vo.UserNoticePageVO; +import com.rnb.system.model.vo.NoticeDetailVO; +import com.rnb.system.service.NoticeService; +import com.rnb.system.service.UserNoticeService; +import com.rnb.system.service.UserOnlineService; +import com.rnb.system.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.simp.SimpMessagingTemplate; +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 SimpMessagingTemplate messagingTemplate; + private final UserOnlineService userOnlineService; + + /** + * 获取通知公告分页列表 + * + * @param queryParams 查询参数 + * @return {@link IPage< NoticePageVO >} 通知公告分页列表 + */ + @Override + public IPage getNoticePage(NoticePageQuery queryParams) { + Page noticePage = this.baseMapper.getNoticePage( + new Page<>(queryParams.getPageNum(), queryParams.getPageSize()), + queryParams + ); + return noticeConverter.toPageVo(noticePage); + } + + /** + * 获取通知公告表单数据 + * + * @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), + User::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(User::getUsername).collect(Collectors.toSet()); + + Set allOnlineUsers = userOnlineService.getOnlineUsers().stream() + .map(UserOnlineService.UserOnlineDTO::getUsername) + .collect(Collectors.toSet()); + + // 找出在线用户的通知接收者 + Set onlineReceivers = new HashSet<>(CollectionUtil.intersection(receivers, allOnlineUsers)); + + NoticeDTO noticeDTO = new NoticeDTO(); + noticeDTO.setId(id); + noticeDTO.setTitle(notice.getTitle()); + noticeDTO.setType(notice.getType()); + noticeDTO.setPublishTime(notice.getPublishTime()); + + onlineReceivers.forEach(receiver -> messagingTemplate.convertAndSendToUser(receiver, "/queue/message", noticeDTO)); + } + 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) + ); + } + return revokeResult; + } + + /** + * + * @param id 通知公告ID + * @return NoticeDetailVO 通知公告详情 + */ + @Override + public NoticeDetailVO getNoticeDetail(Long id) { + NoticeBO noticeBO = 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 noticeConverter.toDetailVO(noticeBO); + } + + /** + * 获取当前登录用户的通知公告列表 + * + * @param queryParams 查询参数 + * @return 通知公告分页列表 + */ + @Override + public IPage getMyNoticePage(NoticePageQuery queryParams) { + queryParams.setUserId(SecurityUtils.getUserId()); + return userNoticeService.getMyNoticePage( + new Page<>(queryParams.getPageNum(), queryParams.getPageSize()), + queryParams + ); + } + +} diff --git a/src/main/java/com/rnb/system/service/impl/RoleMenuServiceImpl.java b/src/main/java/com/rnb/system/service/impl/RoleMenuServiceImpl.java new file mode 100644 index 0000000..8f81a74 --- /dev/null +++ b/src/main/java/com/rnb/system/service/impl/RoleMenuServiceImpl.java @@ -0,0 +1,126 @@ +package com.rnb.system.service.impl; + +import cn.hutool.core.collection.CollectionUtil; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.rnb.common.constant.RedisConstants; +import com.rnb.system.mapper.RoleMenuMapper; +import com.rnb.system.model.bo.RolePermsBO; +import com.rnb.system.model.entity.RoleMenu; +import com.rnb.system.service.RoleMenuService; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Set; + +/** + * 角色菜单服务实现类 + * + * @author Ray.Hao + * @since 2.5.0 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class RoleMenuServiceImpl extends ServiceImpl implements RoleMenuService { + + private final RedisTemplate redisTemplate; + + /** + * 初始化权限缓存 + */ + @PostConstruct + public void initRolePermsCache() { + log.info("初始化权限缓存... "); + refreshRolePermsCache(); + } + + /** + * 刷新权限缓存 + */ + @Override + public void refreshRolePermsCache() { + // 清理权限缓存 + redisTemplate.opsForHash().delete(RedisConstants.System.ROLE_PERMS, "*"); + + 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(RedisConstants.System.ROLE_PERMS, roleCode, perms); + } + }); + } + } + + /** + * 刷新权限缓存 + */ + @Override + public void refreshRolePermsCache(String roleCode) { + // 清理权限缓存 + redisTemplate.opsForHash().delete(RedisConstants.System.ROLE_PERMS, roleCode); + + List list = this.baseMapper.getRolePermsList(roleCode); + if (CollectionUtil.isNotEmpty(list)) { + RolePermsBO rolePerms = list.get(0); + if (rolePerms == null) { + return; + } + + Set perms = rolePerms.getPerms(); + if (CollectionUtil.isNotEmpty(perms)) { + redisTemplate.opsForHash().put(RedisConstants.System.ROLE_PERMS, roleCode, perms); + } + } + } + + /** + * 刷新权限缓存 (角色编码变更时调用) + */ + @Override + public void refreshRolePermsCache(String oldRoleCode, String newRoleCode) { + // 清理旧角色权限缓存 + redisTemplate.opsForHash().delete(RedisConstants.System.ROLE_PERMS, oldRoleCode); + + // 添加新角色权限缓存 + List list = this.baseMapper.getRolePermsList(newRoleCode); + if (CollectionUtil.isNotEmpty(list)) { + RolePermsBO rolePerms = list.get(0); + if (rolePerms == null) { + return; + } + + Set perms = rolePerms.getPerms(); + redisTemplate.opsForHash().put(RedisConstants.System.ROLE_PERMS, newRoleCode, perms); + } + } + + /** + * 获取角色权限集合 + * + * @param roles 角色编码集合 + * @return 权限集合 + */ + @Override + public Set getRolePermsByRoleCodes(Set roles) { + return this.baseMapper.listRolePerms(roles); + } + + /** + * 获取角色拥有的菜单ID集合 + * + * @param roleId 角色ID + * @return 菜单ID集合 + */ + @Override + public List listMenuIdsByRoleId(Long roleId) { + return this.baseMapper.listMenuIdsByRoleId(roleId); + } + +} diff --git a/src/main/java/com/rnb/system/service/impl/RoleServiceImpl.java b/src/main/java/com/rnb/system/service/impl/RoleServiceImpl.java new file mode 100644 index 0000000..c71923b --- /dev/null +++ b/src/main/java/com/rnb/system/service/impl/RoleServiceImpl.java @@ -0,0 +1,257 @@ +package com.rnb.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.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.rnb.common.exception.BusinessException; +import com.rnb.system.converter.RoleConverter; +import com.rnb.system.mapper.RoleMapper; +import com.rnb.system.model.entity.Role; +import com.rnb.system.model.entity.RoleMenu; +import com.rnb.system.model.form.RoleForm; +import com.rnb.system.model.query.RolePageQuery; +import com.rnb.system.model.vo.RolePageVO; +import com.rnb.common.constant.SystemConstants; +import com.rnb.common.model.Option; +import com.rnb.core.security.util.SecurityUtils; +import com.rnb.system.service.RoleMenuService; +import com.rnb.system.service.RoleService; +import com.rnb.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.Set; + +/** + * 角色业务实现类 + * + * @author haoxr + * @since 2022/6/3 + */ +@Service +@RequiredArgsConstructor +public class RoleServiceImpl extends ServiceImpl implements RoleService { + + private final RoleMenuService roleMenuService; + private final UserRoleService userRoleService; + private final RoleConverter roleConverter; + + /** + * 角色分页列表 + * + * @param queryParams 角色查询参数 + * @return {@link Page< RolePageVO >} – 角色分页列表 + */ + @Override + public Page getRolePage(RolePageQuery 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