# spring-boot-3-template **Repository Path**: rxl2000/spring-boot-3-template ## Basic Information - **Project Name**: spring-boot-3-template - **Description**: No description available - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-09-13 - **Last Updated**: 2026-09-14 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # Spring Boot 3 项目初始化模板 基于 **Java 21 + Spring Boot 3.5** 的后端项目模板,整合了常用的框架、中间件与工程化约定,clone 下来改几个配置就能直接开发业务。 ## 技术栈 | 分类 | 选型 | |---|---| | 语言 / 框架 | Java 21、Spring Boot 3.5.16、Spring MVC、Spring AOP | | 数据访问 | MyBatis-Plus 3.5.17(分页、逻辑删除、乐观锁、防全表更新) | | 数据库 | MySQL 8.4 + HikariCP | | 缓存 | Redis + Caffeine(`@Cacheable` 默认走 Caffeine,由 `spring.cache.type` 显式指定) | | 分布式锁 | Redisson | | 认证鉴权 | **Sa-Token 1.44.0**(Redis 存储,支持注解鉴权 / 多端登录 / 踢人下线) | | 消息队列 | RabbitMQ(Spring AMQP) | | 定时任务 | Spring `@Scheduled`(已解耦,可平滑迁移 XXL-Job) | | 接口文档 | SpringDoc 2.9.0 + Swagger UI(knife4j 依赖保留但默认关闭,见下方说明) | | 密码加密 | Spring Security Crypto(BCrypt) | | 工具库 | Hutool、Commons Lang3、Lombok | | 代码生成 | MyBatis-Plus Generator(FreeMarker 模板) | ## 快速开始 ### 1. 配置环境变量(WSL2 用户必做) ```bash cp .env.example .env # 编辑 .env,填入 WSL 发行版的 IP: # hostname -I | awk '{print $1}' ``` **原生 Linux / macOS / Windows 可跳过这步**,默认值即可工作。 > ⚠️ **WSL2 + Docker Desktop 必须配置这个**,否则 nginx 永远反代不通。 > 原因见下方「WSL2 网络问题」一节。 ### 2. 启动依赖中间件 ```bash docker compose up -d ``` 会拉起: | 服务 | 端口 | 说明 | |---|---|---| | nginx | **80** | 前端静态资源 + 反代 `/api` 到宿主机应用 | | MySQL | 3306 | 库 `renix` 自动创建,初始化脚本见 `data/mysql/init/` | | Redis | 6379 | 密码 `rxl2000` | | RabbitMQ | 5672 / **15672** | 管理后台 http://localhost:15672 (guest / guest) | 首次启动会自动执行 `data/mysql/init/init.sql` 建表,并插入管理员 **admin / 12345678**。 想重建数据库:`docker compose down -v && rm -rf data/mysql/data` ### 3. 核对应用配置 默认 `dev` 环境,配置在 `src/main/resources/application-dev.yaml`。 **这里的账号密码必须与 `docker-compose.yaml` 一致,改一处要同步改另一处** —— 尤其 Redis,`RedissonConfig` 在启动时就会连接,密码对不上会直接导致启动失败。 ### 4. 启动应用 ```bash ./mvnw spring-boot:run ``` | 入口 | 地址 | |---|---| | 直连应用 | http://localhost:8080/api/... | | 经 nginx | http://localhost/api/... | | 接口文档 | http://localhost:8080/api/swagger-ui.html | | 健康检查 | http://localhost:8080/api/actuator/health | ### 5. 运行测试 / 打包 `ApplicationTests` 是 `@SpringBootTest`,**会加载完整上下文,因此需要 MySQL 和 Redis 已启动**(即先执行第 2 步)。 `LogUtilsTest`、`PageRequestTest`、`UserDtoToStringTest` 是纯单元测试,**不加载 Spring 上下文、不需要任何中间件**,可以随时单独跑: ```bash # 只跑不需要中间件的单元测试 ./mvnw test -Dtest='LogUtilsTest,PageRequestTest,UserDtoToStringTest' ./mvnw test # 全量,需要中间件在跑 ./mvnw package -DskipTests # 跳过测试打包 ``` > `UserDtoToStringTest` 是**回归守卫**:新增带密码/密钥字段的 DTO 时如果忘了加 `@ToString.Exclude`,它会直接失败(背景见下方「日志脱敏」)。 > 注意:`RedissonConfig` 在启动时就会连接 Redis(`Redisson.create()` 是即时连接),所以 **Redis 不可用时应用会直接启动失败**,而不是等到用锁时才报错。这是有意为之——Redis 是本模板的硬依赖,早失败比晚失败好排查。若希望改为懒连接,可把 `RedissonClient` 的注入点标上 `@Lazy`。 ## 目录结构 ``` src/main/java/com/renix ├── Application.java 启动入口(@EnableCaching / @EnableAspectJAutoProxy) ├── aop/ 切面:LogInterceptor 请求日志 ├── common/ 通用返回:BaseResponse / ResultUtils / ErrorCode / PageRequest / DeleteRequest ├── config/ 配置类(见下方说明) ├── constant/ 常量 ├── controller/ 接口层:UserController(示例) ├── exception/ BusinessException / GlobalExceptionHandler / ThrowUtils ├── generate/ CodeGenerator 代码生成器(手动运行,不参与启动) ├── mapper/ 数据访问层:UserMapper ├── model/ │ ├── entity/ 数据库实体:User │ ├── dto/user/ 入参 DTO:UserRegisterDTO / UserLoginDTO / UserAddDTO │ │ UserUpdateDTO / UserUpdateMyDTO / UserQueryDTO │ ├── vo/ 响应视图:UserVO(脱敏)/ LoginUserVO │ └── enums/ UserRoleEnum ├── service/ 业务层:UserService │ └── impl/ UserServiceImpl ├── task/ 定时任务:TaskHandler / TaskHandlerRegistry / SampleTask └── utils/ LogUtils 日志脱敏(被 LogInterceptor 调用) ``` ``` data/ 中间件的配置与运行时数据(部分纳入版本控制) ├── mysql/ │ ├── conf/charset.cnf 客户端字符集,防止初始化脚本中文乱码 │ ├── init/init.sql 建表脚本,首次启动自动执行 │ └── data/ 数据库文件(gitignored) ├── nginx/ │ ├── nginx.conf 主配置 │ ├── conf.d/default.conf 站点配置:静态资源 + /api 反代 │ ├── html/ 前端构建产物(gitignored) │ └── logs/ (gitignored) └── redis/data/ (gitignored) ``` > `.gitignore` 里**刻意没有写 `data/`**,否则会把 `mysql/init`、`nginx/*.conf` 这些需要进版本控制的配置文件一起忽略掉(git 无法只排除父目录却保留子目录)。只忽略真正的运行时数据。 `User` 模块是一套**完整的垂直切片**,可作为新业务的参照实现: | 层 | 文件 | 说明 | |---|---|---| | 接口 | `UserController` | 只做「接参 → 调 Service → 包响应」,不写业务逻辑 | | 业务 | `UserService` / `UserServiceImpl` | 业务逻辑、事务、密码加密、权限判断 | | 数据 | `UserMapper` | 继承 `BaseMapper`,单表 CRUD 零代码 | | 实体 | `model/entity/User` | `@TableName` / `@TableId` / `@TableLogic` | | 请求 | `model/dto/user/*` | 带 `@Valid` 校验注解 | | 响应 | `model/vo/UserVO` | 脱敏视图,绝不让实体直接出参 | 示例接口:`/user/register`、`/user/login`、`/user/logout`、`/user/get/login`、`/user/get/vo`、`/user/add`、`/user/update`、`/user/delete`、`/user/list/page/vo`、`/user/update/my`。初始管理员:**admin / 12345678**(上线前务必改密码或删掉)。 鉴权要求: | 接口 | 要求 | |---|---| | `/user/register`、`/user/login` | 匿名可访问 | | `/user/logout`、`/user/get/login`、`/user/get/vo`、`/user/update/my` | `@SaCheckLogin` | | `/user/add`、`/user/update`、`/user/delete`、`/user/list/page/vo` | `@SaCheckRole("admin")` | > `/user/add` 的初始密码由管理员在请求体里**显式指定**(`userPassword` 字段,8-32 位)。 > 早期版本是服务端硬编码的 `12345678`,注释写着「要求首次登录后修改」但并没有强制改密机制 —— > 等于所有新建账号共用一个已知密码,已改掉。 > > `/user/get/vo` 返回的是脱敏视图,但仍包含账号与角色,因此要求登录(原先是匿名可访问)。 ### config 包说明 | 类 | 作用 | |---|---| | `MybatisPlusConfig` | 分页、乐观锁、防全表更新删除插件;`@MapperScan` | | `RedisTemplateConfig` | key 用 String、value 用 JSON 序列化 | | `RedissonConfig` | 分布式锁客户端,复用 `spring.data.redis` 配置 | | `JacksonConfig` | Long 转 String(防前端精度丢失)、时间格式统一 | | `WebMvcConfig` | 跨域 + **Sa-Token 拦截器**(注解鉴权的前提) | | `StpInterfaceImpl` | **权限数据源**,Sa-Token 回调它获取角色/权限,详见「认证鉴权」 | | `Knife4jConfig` | 接口文档,仅 dev / test 生效 | | `SchedulingConfig` | 定时任务线程池(避免默认单线程串行阻塞) | | `RabbitMQConfig` | 消息体 JSON 序列化 | ## 关键约定 ### 统一响应 所有接口返回 `BaseResponse`,用 `ResultUtils.success(data)` 包装;业务失败直接抛 `BusinessException` 或使用 `ThrowUtils.throwIf(...)`,由 `GlobalExceptionHandler` 统一转换。 **兜底处理器只回固定文案**,不把 `e.getMessage()` 返回给调用方。运行时异常的 message 由框架生成,会把内部细节泄露出去,实测: | 异常 | `e.getMessage()` 的内容 | |---|---| | MyBatis 列名写错 | `### selectList; bad SQL grammar [SELECT id,user_account FROM sys_user WHERE (userAccount = ?)]` | | 空指针(Java 14+) | `Cannot invoke "com.renix.model.dto.user.UserLoginDTO.getUserAccount()" because "" is null` | 判断标准是 **message 由谁写的**:`BusinessException` 的文案是开发者写的(可以回给前端,如「账号或密码错误」),运行时异常的不是。 ### 日志脱敏(新增 DTO 时必看) 请求日志切面会把入参 `toString()` 后以 **INFO** 级别打出来,而 Lombok 的 `@Data` **默认把全部字段放进 toString** —— 密码会明文进日志,生产环境还会落盘保留 30 天。 所以凡是带密码 / 密钥 / token 字段的 DTO,**必须给这些字段加 `@ToString.Exclude`**: ```java @NotBlank(message = "密码不能为空") @Size(min = 8, max = 32, message = "密码长度需在 8-32 之间") @ToString.Exclude // ← 漏了这行,密码就会进日志 private String userPassword; ``` `LogInterceptor.buildParams()` 里还有一层 `LogUtils.maskSensitive()` 正则兜底(识别 `password` / `token` / `secret` / `credential` 等关键字),但**字段名取得不巧就会漏**,它是第二道防线而不是主防线。 `UserDtoToStringTest` 会守住这条线:新 DTO 忘了加注解,测试直接失败。 ### 认证鉴权 登录态由 Sa-Token 管理,token 通过请求头 `satoken` 传递(前后端分离,不读 cookie)。 ```java @PostMapping("/login") public BaseResponse login(@Valid @RequestBody UserLoginDTO loginDTO) { // 校验账号密码... StpUtil.login(userId); return ResultUtils.success(StpUtil.getTokenValue()); } @GetMapping("/me") @SaCheckLogin // 需登录 public BaseResponse me() { ... } @PostMapping("/delete") @SaCheckRole("admin") // 需 admin 角色 public BaseResponse delete(...) { ... } @PostMapping("/add") @SaCheckPermission("user:add") // 需指定权限 public BaseResponse add(...) { ... } ``` > ⚠️ **注解鉴权需要两样东西同时在场,缺任何一个都会静默失效**(不报错,注解被当空气): > > 1. **`WebMvcConfig` 里注册的 `SaInterceptor`** —— `@SaCheckLogin` / `@SaCheckRole` / `@SaCheckPermission` 全靠它生效。不要移除那段 `addInterceptors`,这是 Sa-Token 最经典的坑。 > 2. **`StpInterfaceImpl`** —— Sa-Token 只管「你是谁」(token → loginId),不知道「你能干什么」。`StpInterface` 是它留给你的空槽:校验角色/权限时框架回调你的实现,问「这个账号有哪些角色」。不实现的话框架退回默认实现,两个方法都返回空集合,**所有角色/权限校验必然失败**。 `StpInterfaceImpl` 当前的实际行为: | 方法 | 返回值 | |---|---| | `getRoleList(loginId)` | 查 `sys_user`,返回该用户的 `user_role`(`user` / `admin` / `ban`) | | `getPermissionList(loginId)` | 管理员返回通配符 `["*"]`,其余返回空集合(即普通用户过不了任何 `@SaCheckPermission`) | 三点需要注意: - **`@SaCheckLogin` 完全不碰 `StpInterface`**,它只校验 token 有效性 —— 「未登录」和「没权限」是两条独立的路。 - **框架默认不缓存 `StpInterface` 的返回值**(官方 javadoc 原话:「框架默认不对数据进行缓存」)。所以每个带 `@SaCheckRole` / `@SaCheckPermission` 的请求都会查一次库。角色数据变更不频繁,值得加缓存 —— 但注意写法:**直接把 `@Cacheable` 加在 `StpInterfaceImpl.findUser()` 上不会生效**,它是 private 方法(Spring 缓存基于 AOP 代理,private 不被代理),而且 `getRoleList` 是 `this.findUser(...)` 自调用(自调用同样绕过代理)。正确做法是把这段查询抽成独立的 Bean 再挂 `@Cacheable`,并配 `@CacheEvict`。 - **管理员拿的是通配符 `*`**,等于 `@SaCheckPermission` 永远约束不到管理员。方便,但如果将来要做「admin A 不能碰财务数据」这类细粒度控制,必须把这个通配符拿掉。 **扩展成细粒度权限**:当前只有三级角色、直接存在 `sys_user.user_role` 字段里,所以实现只有两行。如果需要「一个用户多个角色」或权限码体系,要建 `sys_role` / `sys_permission` / 关联表,并在这里改成联表查询。 > **封禁与删除都会主动踢下线**:`userLogin` 拒绝 `ban` 用户登录;`userUpdate` 把角色改成 `ban`、以及 `userDelete` 删除用户时,都会调用 `StpUtil.kickout(id)` 失效该账号已发出的全部 token。 > 不做这一步的话只挡住了新登录 —— 已拿到 token 的会话在有效期内(默认 30 天)依然能访问所有 `@SaCheckLogin` 接口。 > 另外 `userUpdate` 不允许管理员修改**自己**的角色,避免误操作把自己锁在系统外。 ### 定时任务 业务逻辑写在 `TaskHandler` 实现类里,调度入口与业务解耦,便于将来迁移到 XXL-Job: ```java @Component public class MyTask implements TaskHandler { @Override public String name() { return "myTask"; } @Override public void execute(String param) { /* 业务逻辑 */ } @Scheduled(cron = "0 0 2 * * ?") // 每天凌晨 2 点 public void run() { registry.execute(name(), null); } } ``` 迁移到 XXL-Job 时,只需把 `@Scheduled` 换成 `@XxlJob("myTask")`,业务代码不动。 ### 代码生成器 运行 `generate/CodeGenerator.java` 的 `main` 方法,按提示输入表名,生成 entity / mapper / xml / service / serviceImpl / controller。 新增的表建议带 `is_delete`(逻辑删除)字段,与全局配置保持一致。 ## 环境配置 | 文件 | 用途 | |---|---| | `application.yaml` | 各环境共用(MyBatis-Plus、Sa-Token、Jackson、接口文档等) | | `application-dev.yaml` | 本地开发,打印 SQL | | `application-test.yaml` | 测试环境 | | `application-prod.yaml` | 生产环境,**连接信息走环境变量**,关闭接口文档与 SQL 日志 | 生产环境通过环境变量注入敏感配置:`MYSQL_HOST` / `MYSQL_PASSWORD` / `REDIS_HOST` / `REDIS_PASSWORD` / `RABBITMQ_HOST` / `CORS_ALLOWED_ORIGINS` 等。 > ⚠️ **`CORS_ALLOWED_ORIGINS` 必须设置**。`application.yaml` 里的默认值是 `http://localhost:[*],http://127.0.0.1:[*]`, > 而 `application-prod.yaml` 不覆盖的话会继承它 —— 线上继续放行来自 localhost 的带凭证跨域请求。 ## 部署 ```bash docker build -t renix:latest . docker run -d -p 8080:8080 \ -e MYSQL_HOST=your-mysql \ -e MYSQL_PASSWORD=your-password \ renix:latest ``` ## ⚠️ WSL2 网络问题(nginx 反代不通的根因) **症状**:nginx 容器起来了,但访问 `http://localhost/api/...` 报错。nginx 的 `error.log` 里是: ``` connect() failed (111: Connection refused) while connecting to upstream, upstream: "http://192.168.65.254:8080/api/..." ``` **原因**:容器里的 `host.docker.internal` 解析到的是 **Docker 虚拟机的网关**(`192.168.65.254`),**不是**你跑应用的 WSL 发行版。应用在 WSL 里,容器够不着它。 实测确认(本机为 WSL2 + Docker Desktop): | 从容器访问 | 结果 | |---|---| | `host.docker.internal:8080` | ✗ Connection refused | | `172.17.0.1:8080`(docker0 网关) | ✗ Connection refused | | `127.0.0.1:8080`(**host 网络模式下**) | ✗ Connection refused | | **WSL 发行版 IP :8080** | **✓ 可达** | 连 host 网络模式下的 `127.0.0.1` 都不通 —— 说明容器的"宿主机"是 Docker 虚拟机,与 WSL 是两个网络命名空间。 **解决**:把 WSL 的 IP 通过 `extra_hosts` 映射成 `app.host`,nginx 反代到它。 ```bash # 1. 取 IP hostname -I | awk '{print $1}' # 2. 写进 .env echo "WSL_HOST_IP=上一步的IP" > .env # 3. 重建 nginx 容器 docker compose up -d --force-recreate nginx ``` > ⚠️ **WSL 每次重启 IP 都可能变**,变了之后要重复第 2、3 步。 > `proxy_pass` 里写的是固定主机名,nginx 启动时解析一次,所以必须重建容器而不是 reload。 **替代方案**:把应用也加进 `docker-compose.yaml` 作为一个 service,nginx 反代到 `app:8080`(走 compose 内部网络),彻底绕开宿主机网络问题。代价是改代码要重新构建镜像。 ## ⚠️ 关于 knife4j(重要) pom 里保留了 `knife4j-openapi3-jakarta-spring-boot-starter`,但 **`knife4j.enable: false`**,接口文档走 springdoc 原生的 Swagger UI。 **为什么不开启**:knife4j **4.5.0 是它的最后一个版本**(2024-01 发布,此后停更),内部调用了 springdoc 2.3.0 的 `SpringDocConfigProperties.getGroupConfigs()`,而该方法在 springdoc 2.9.0 中已被移除。开启后 `/v3/api-docs` 会抛: ``` java.lang.NoSuchMethodError: 'java.util.List org.springdoc.core.properties.SpringDocConfigProperties.getGroupConfigs()' ``` 表现为 `doc.html` 能打开(静态页),但接口列表空白、文档接口 500。 **如果要启用 knife4j 的中文 UI**,必须同时把 `pom.xml` 的 `springdoc.version` 降到 `2.3.0`。该组合已验证可用,但 springdoc 2.3.0 官方只支持到 Spring Boot 3.2,属于「能用但不受支持」的状态,升级 Spring Boot 时需重新验证。 > 这个坑很隐蔽:只开 `knife4j.enable` 而不管 springdoc 版本,启动阶段完全正常,直到有人打开文档页才会发现。 ## ⚠️ 三个容易踩的坑(已实测确认) ### 1. MyBatis-Plus 3.5.17 改了包名 `IService` / `ServiceImpl` 的包路径在 **3.5.17 变更**了(补丁版本里的破坏性变更): ```java // 3.5.16 及更早(网上教程、老项目、鱼皮模板全都是这个) import com.baomidou.mybatisplus.extension.service.IService; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; // 3.5.17 起 import com.baomidou.mybatisplus.spring.service.IService; import com.baomidou.mybatisplus.spring.service.impl.ServiceImpl; ``` 从任何旧资料复制 Service 代码过来都会编译不过,报 `package com.baomidou.mybatisplus.extension.service does not exist`。 其他常用类**未受影响**:`BaseMapper` 仍在 `core.mapper`,`QueryWrapper` 仍在 `core.conditions.query`,`Page` / `MybatisPlusInterceptor` 仍在 `extension.plugins`。 ### 2. 列名用下划线风格,`QueryWrapper` 里必须写数据库列名 本模板的数据库列名是**下划线风格**(`user_account`),配置项 `map-underscore-to-camel-case: true`。 而鱼皮模板用的是**驼峰列名**(`userAccount`)。这个差异很关键,因为 `QueryWrapper` 的字符串参数会被**当作列名原样拼进 SQL**,不做驼峰转换: ```java // ✅ 本模板(下划线列名) queryWrapper.eq("user_account", userAccount); // ❌ 照抄鱼皮模板会报 Unknown column 'userAccount' queryWrapper.eq("userAccount", userAccount); ``` `map-underscore-to-camel-case` 只影响**查询结果的映射**(`user_account` → `userAccount` 字段),不影响 `QueryWrapper` 拼 SQL。 排序字段同理,`UserServiceImpl` 里维护了 `SORTABLE_FIELDS` 白名单,写的是数据库列名。 ### 3. `init.sql` 里的 `SET NAMES utf8mb4` 不能删 MySQL 容器执行 `docker-entrypoint-initdb.d` 下的脚本时,客户端连接默认不是 utf8mb4,脚本里的中文会被双重编码后存入数据库:`管理员` → `管ç†å‘˜`(20 字节),**且完全静默、不报任何错**。 `sql/init.sql` 开头的 `SET NAMES utf8mb4;` 就是修这个的,删掉就会复现。 > 注意:这只影响 **init 脚本**。应用通过 JDBC 读写的路径是正常的(连接串已带 `characterEncoding=utf-8`,已实测中文字段双向往返无误)。 ## 已做的工程化处理 **安全** - **请求参数日志脱敏**:DTO 的密码字段加 `@ToString.Exclude`,`LogUtils.maskSensitive()` 再做一层正则兜底。实测登录/注册不再把明文密码写进日志 - **异常不泄露内部信息**:兜底处理器只回固定文案,不把 `e.getMessage()` 返回给调用方 —— 否则 MyBatis 异常会泄露完整 SQL 与表名,空指针(Java 14+ 的 helpful NPE)会泄露内部类结构 - **封禁 / 删除时踢下线**:`StpUtil.kickout()`,避免「只挡住新登录,已发出的 token 还能继续用 30 天」 - `userUpdate` 不允许管理员修改自己的角色(与 `userDelete` 的「不能删除自己」同一类保护) - 角色值写入前用 `UserRoleEnum` 校验,防止写错字符串导致鉴权静默失效 - 排序字段走 `SORTABLE_FIELDS` 硬白名单校验(`UserServiceImpl`),防止 ORDER BY 注入 - MyBatis-Plus 防全表更新删除插件,拦截无 where 条件的 update / delete - 登录不区分「账号不存在」与「密码错误」,防账号枚举 - `userUpdateMy` 的 id 取自登录态而非请求参数,防越权改他人资料 - 分页参数上限收敛,防止一次拉取过多数据 - 生产环境关闭接口文档与 SQL 日志,日志按大小滚动 - Dockerfile 多阶段构建 + 非 root 用户运行 + 容器感知 JVM 参数 **可观测性** - 全局异常处理覆盖:业务异常、Sa-Token 鉴权异常、`@Valid` 参数校验、请求体解析失败、404、方法不支持 - 日志分级:可预期的业务失败 WARN 且不打堆栈,系统故障 ERROR 且打堆栈 - 日志切面记录 requestId / URL / IP / 参数 / 耗时,并正确处理反向代理后的真实 IP - requestId 写入 MDC,Controller / Service / Mapper 打印的日志按同一个 id 串联(格式见 `application.yaml` 的 `logging.pattern.level`) - 长整型转字符串,避免前端 JS 精度丢失