Spring Boot实战:从分页查询到Token鉴权与安全加固
简介基于Springboot的文化旅游信息网站项目面向需要快速搭建文旅类Web应用的开发者、毕业设计或课程实践人群。项目采用Spring Boot框架内置Tomcat且遵循自动配置原则可实现旅游线路推荐、文化活动发布、景区与美食信息管理等常见业务模块。压缩包共176个文件大小约10.54MB包含84个java源码文件、82个jpg图片资源、2个xml配置、1个yml配置、1个sql数据库脚本另有mvnw、cmd、gitignore、md说明文档等覆盖后端逻辑、页面展示、数据库初始化与启动脚本结构清晰便于直接导入IDE运行。已有66人学习下载。通过这份资源可掌握Spring Boot项目的完整目录结构、自动配置与依赖管理方式了解JPA/MyBatis数据库连接、Thymeleaf模板渲染、Spring Security安全配置以及前后端接口设计思路适合作为Web开发实战参考或二次开发基础。1. 从文件列表反推架构这个 Spring Boot 项目的代码地图第一次拆这种工程的人最容易被 src/main/java 下面那一排 ServiceImpl 晃花眼——Jingqujingdianfenlei景点分类、Discussmeishituijian美食推荐讨论、Discussjingqujingdian景区评论光看命名就知道这个基于 Spring Boot 的文化旅游信息网站把内容管理塞进了一套标准三层架构里。它不是只有一个增删改查的演示项目TokenServiceImpl 和 AuthorizationInterceptor 负责接口鉴权Query 类配合 MyBatis-Plus 做分页BaiduUtil 走的是百度开放平台整套东西串起来就是一个能跑通「登录 → 浏览 → 评论」闭环的 Web 应用。适合正在做 Java 毕设的在校生也适合想快速补齐 Spring Boot MyBatis-Plus 实战经验的后端工程师。下面按「跑起来 → 看鉴权 → 拆业务 → 做加固」的顺序把代码逐个敲开。2. 从 mvnw.cmd 到 application.yml把工程跑起来的配置清单2.1 Maven Wrapper 与版本陷阱拿到项目压缩包后我一般先在根目录扫一眼有没有 mvnw.cmd 和 .mvn/wrapper 目录。有这个文件说明工程是用 Spring Initializr 生成的 Maven 项目Maven Wrapper 会把 Maven 版本锁死在 wrapper.properties 里避免「我本地 3.6、你本地 3.9」的环境扯皮。在 Windows 里直接用 mvnw.cmd 就好不要手动敲 mvn否则 JDK 和 Maven 版本对不上时第一行就会报 UnsupportedClassVersionError跟业务代码毫无关系。# 在项目根目录执行跳过测试打包 .\mvnw.cmd clean package -DskipTests # 本地直接启动终端里 CtrlC 停掉 .\mvnw.cmd spring-boot:run第一条命令会生成 target/xxx.jar第二条命令适合开发时热启动。如果后面要部署到宝兰德这类 Java 容器而不是内嵌 Tomcat需要把打包方式改成 war并在主启动类继承 SpringBootServletInitializer这个项目默认是 jar 方式改之前先想清楚部署环境。提示如果 pom.xml 的 parent 是 spring-boot-starter-parent 2.7.x别手滑升到 3.x。Spring Boot 3 把 javax 全部换成了 jakarta 包名低版本的 MyBatis-Plus、PageHelper 这类库直接编译失败。这个项目按 2.7 周边生态写的最稳的组合是 Spring Boot 2.7.13 MyBatis-Plus 3.5.3.x这也是毕设源码里最常见的搭配。2.2 pom.xml 里要锁死的依赖组合项目正文里没有列出 pom.xml但 Spring Boot 工程必然带着它。核心依赖只有三个起步依赖一个管 Web、一个管持久层、一个管数据库驱动Spring Boot 的自动配置原理在这里体现得最直接引入 starter 之后内嵌 Tomcat、DispatcherServlet、数据源配置全部按约定自动装配你只需要写业务代码。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.13/version relativePath/ /parent dependencies !-- Web 起步依赖内嵌 Tomcat Spring MVC 自动装配 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- MyBatis-Plus单表 CRUD、分页、条件构造器都靠它 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency !-- MySQL 8.x 驱动runtime 作用域只参与运行 -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency /dependencies这里面最容易翻车的是 MyBatis-Plus 版本。3.5.3.1 对应 Spring Boot 2.x 的 starter3.5.7 之后才全面兼容 Spring Boot 3。如果你是照着 idea 里新建 springboot 项目的方式重新生成了一遍默认给的 Spring Boot 3.x 会让这个项目的持久层全部失效所以别用 IDEA 默认的最新版去套老代码。组件版本选择原因Spring Boot2.7.13稳定且大量毕设源码基于此版本资料好找MyBatis-Plus3.5.3.1兼容 Spring Boot 2.7自带分页插件MySQL 驱动8.x由 Spring Boot 依赖管理锁定兼容 MySQL 5.7 与 8.x2.3 application.yml 数据源与 MyBatis-Plus 逻辑删除配置文件写在 src/main/resources 下优先用 yml 而不是 properties。下面这一段是这类网站项目的标准配置server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/culture_travel?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 mybatis-plus: configuration: # 数据库下划线字段自动映射到驼峰属性 map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl global-config: db-config: # 所有表统一用 deleted 字段做逻辑删除 logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0url 里的 serverTimezoneAsia/Shanghai 不能省否则连接 MySQL 8.x 会报时区错误。characterEncodingutf8 解决中文乱码景点名称、美食推荐文案这些字段一旦乱码排查成本特别高。logic-delete-field 的作用是调用 MyBatis-Plus 的 deleteById 时底层不是执行 DELETE而是 UPDATE 把 deleted 置 1查询时自动拼接 deleted0。评论、用户这类数据千万别做物理删除不然以后想恢复、想做统计分析都没有数据来源。如果你的表没有 deleted 字段把这段配置去掉否则所有查询会自动带上 deleted0 条件查不出数据。2.4 Query 类分页参数直接继承 Page项目里的 Query.java 是 MyBatis-Plus 分页的最常用写法直接继承 Page 类让 Controller 接收的参数对象本身就是分页对象省去手动转换public class QueryT extends PageT { private Integer pageNum 1; private Integer pageSize 10; public Query() { super(1, 10); } public Query(Integer pageNum, Integer pageSize) { super(pageNum, pageSize); this.pageNum pageNum; this.pageSize pageSize; } /** * 前端传 pageNum 时同时设置 Page 的 current * 否则 Page 默认 current0第一页数据会查不出来 */ public void setPageNum(Integer pageNum) { this.pageNum pageNum; this.setCurrent(pageNum null ? 1 : pageNum); } public void setPageSize(Integer pageSize) { this.pageSize pageSize; this.setSize(pageSize null ? 10 : pageSize); } }这段代码的关键在于 setPageNum 和 setPageSize它们是 setter 方法前端 JSON 反序列化时会自动调用同步把值写进 MyBatis-Plus 的 Page 里。如果你直接在 Controller 里 new Page(pageNum, pageSize)也没有问题只是每个接口都要重复写一遍分页参数解析项目里的 Query 就是把这层重复收拢了。分页插件还需要在配置类里注册 PaginationInnerInterceptor否则 Page 对象传进去后不会真正拼 LIMIT 语句Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }忘了加这个拦截器是最常见的坑Page 能接收参数但 SQL 日志里没有 LIMIT查出来永远是全量数据。启动项目后先看控制台 SQL再判断分页到底生效没有。3. TokenServiceImpl 与 AuthorizationInterceptor这个项目的接口鉴权怎么运作3.1 鉴权方案选型拦截器就够了文化旅游网站这类毕设项目用户角色通常只有普通用户和管理员接口鉴权只需要做到「登录后拿 token访问时验证 token校验通过放行」。用 Spring Security 也能做但过滤器链和权限表达式的学习成本对这个体量来说偏高真正跑起来反而容易因为配置顺序问题把登录接口都拦掉。项目里用 HandlerInterceptor 是更务实的方案。自定义一个拦截器在 preHandle 方法里读取请求头中的 token调用 TokenService 换取用户信息校验失败直接返回 401不放行到 Controller。这个思路和 Spring Security 的 OncePerRequestFilter 本质一样只是少了配置复杂度。3.2 TokenServiceImpltoken 的生成与双向索引TokenServiceImpl 是鉴权的核心负责两件事登录成功后生成 token拦截器请求里用 token 换 userId。常见做法是同时写两条 Redis 记录一条正向、一条反向Service public class TokenServiceImpl implements TokenService { Autowired private RedisTemplateString, Object redisTemplate; /** * 登录成功后调用返回 token 给前端 */ Override public String createToken(String userId) { // UUID 去掉横线长度 32碰撞概率可以忽略 String token UUID.randomUUID().toString().replace(-, ); // 正向索引userId - token方便做单点登录后登录的顶掉前一个 redisTemplate.opsForValue().set(token:uid: userId, token, 30, TimeUnit.MINUTES); // 反向索引token - userId拦截器里靠这个换身份 redisTemplate.opsForValue().set(token:val: token, userId, 30, TimeUnit.MINUTES); return token; } /** * 拦截器调用token 换 userId */ Override public String getUserIdByToken(String token) { if (token null || token.isEmpty()) { return null; } return (String) redisTemplate.opsForValue().get(token:val: token); } }逻辑说明token:uid: 前缀存储的是「哪个用户当前持有的 token」token:val: 前缀存储的是「这个 token 属于哪个用户」。当用户再次登录时createToken 会覆盖原有 token旧 token 立即失效这相当于实现了简单的单点登录。过期时间设为 30 分钟如果业务要求长期在线改成 7 天但每次请求时最好重新计算过期时间也就是滑动续期。如果项目没有接 Redis最省事的退路是 ConcurrentHashMap 存 token但服务重启后所有用户都要重新登录而且无法控制过期时间不建议用在答辩演示之外的场合。注意使用 redisTemplate 之前记得在 pom.xml 里引入 spring-boot-starter-data-redis并在 application.yml 中配置 Redis 连接地址。这类毕设项目通常本地装一个 Redis 5.x默认端口 6379不需要额外密码。3.3 AuthorizationInterceptor从 Header 取 token 到放行AuthorizationInterceptor 是整个鉴权链路的执行入口它要处理三类情况静态资源直接放行、没有 token 返回 401、有 token 但过期也返回 401。Component public class AuthorizationInterceptor extends HandlerInterceptorAdapter { Autowired private TokenService tokenService; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 不是控制器方法比如静态资源映射、错误页直接放行 if (!(handler instanceof HandlerMethod)) { return true; } // 前端约定把 token 放在 Header 里拦截器统一读取 String token request.getHeader(token); String userId tokenService.getUserIdByToken(token); if (userId null) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\token无效或已过期\}); return false; } // 把用户 id 塞进 requestController 里用 RequestAttribute 直接拿 request.setAttribute(userId, userId); return true; } }逻辑说明handler instanceof HandlerMethod 这个判断特别关键。Spring MVC 中带 Controller 的方法会被封装成 HandlerMethod而静态资源配置、默认错误页面走的是 ResourceHttpRequestHandler如果不加这个判断静态资源全部会被拦截去查 token前端页面 CSS、JS 全部加载失败。response.setStatus 返回 401 而不是 200是为了让前端 axios 拦截器能统一判断登录态。前端代码里一般会有类似 response.interceptors.response.use 的逻辑收到 401 就跳转登录页。如果你前端没有做这种封装也可以改成 200 JSON 里 code401 的格式但要保证前后端约定一致。3.4 WebMvcConfig注册拦截器与排除路径有了拦截器类还不够必须注册到 Spring MVC 的拦截器链里。这个项目里的 WebMvcConfig 负责这件事同时要排除登录、注册和 Swagger 文档相关路径Configuration public class WebMvcConfig implements WebMvcConfigurer { Resource private AuthorizationInterceptor authorizationInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authorizationInterceptor) .addPathPatterns(/**) .excludePathPatterns( /login, /register, /swagger-resources/**, /webjars/**, /v2/api-docs, /swagger-ui.html ); } }这里 addPathPatterns(/**) 的意思是拦截所有路径再通过 excludePathPatterns 把不需要登录的接口排除掉。最容易被忽略的是 Swagger 的资源路径如果漏了 swagger-resources 和 webjars在线文档页面本身能打开但文档里的接口定义全部加载不出来。排除路径原因/login、/register未登录时才能访问/swagger-resources/、/webjars/Swagger 静态资源不是业务接口/v2/api-docs、/swagger-ui.html接口文档页面与 JSON 定义/static/**、/favicon.ico前端静态文件见项目实际路由而定需要说明的是排除路径里如果还有首页轮播图、热门景点列表这类「游客可见」的接口也要加进来。更细的做法是给接口加一个 PassToken 自定义注解在拦截器里判断方法或类上是否有该注解有就放行。这个方案比一个个维护排除路径更优雅改动也小适合后面扩展游客浏览权限。4. 景区分类、美食推荐、讨论评论四个 ServiceImpl 背后的业务结构项目文件列表里出现了一组命名一致的 ServiceImpl这是毕设项目标准的命名风格实体名 业务后缀。它们其实已经揭示了数据库设计的大致模样。文件业务含义核心操作JingqujingdianfenleiServiceImpl景区景点分类分类树、按分类筛选DiscussjingqujingdianServiceImpl景区评论评论 CRUD、统计评论数DiscussmeishituijianServiceImpl美食推荐评论评论分页、倒序展示BaiduUtil百度系 API 封装地图、翻译等第三方调用MPUtilMyBatis-Plus 工具分页参数、模糊查询拼接4.1 景区景点分类一次查全量内存组装树景点分类通常会有层级比如「华东 → 江苏 → 苏州园林」。最容易踩坑的写法是在循环里递归查数据库景点不多时没感觉数据一多就产生 N1 查询问题。这个项目的 JingqujingdianfenleiServiceImpl 一般会写成一次查出全量在内存中组装树Service public class JingqujingdianfenleiServiceImpl extends ServiceImplJingqujingdianfenleiMapper, Jingqujingdianfenlei implements JingqujingdianfenleiService { Override public ListJingqujingdianfenlei queryCategoryTree() { // 全量查出来再在内存里拼父子关系 ListJingqujingdianfenlei all this.list(); // 按父级 id 分组一次循环挂好所有子节点 MapInteger, ListJingqujingdianfenlei childrenMap all.stream() .collect(Collectors.groupingBy(Jingqujingdianfenlei::getPid)); all.forEach(item - item.setChildren( childrenMap.getOrDefault(item.getId(), new ArrayList()))); // 只返回顶层节点子节点已经在 children 里 return all.stream() .filter(item - item.getPid() 0) .collect(Collectors.toList()); } }逻辑说明假设分类表有两列关键字段 id 和 pidpid0 表示顶级分类。第一次查出所有分类后用 pid 分组得到「每个父分类下有哪些直接子分类」再遍历一次把 children 挂到对应对象上。最终只返回 pid0 的顶层节点前端拿到的是完整的树形 JSON。这个方法的边界条件是分类层级不能太深而且全表数据量要控制在几千条以内。如果以后要支持全国所有景区点几千条仍然没压力但超过十万条就要改成「按层级懒加载」前端展开某一层时才去查下一层。毕设场景里一次性加载完全够用。4.2 美食推荐讨论分页评论列表怎么拼DiscussmeishituijianServiceImpl 的业务可以猜个大概美食推荐模块下用户可以对某条推荐发表评论这种「一对多」关系的主查询要按时间倒序分页返回。MyBatis-Plus 里用 LambdaQueryWrapper 是最常见的拆解方式Override public PageMeishiTuijianVO queryDiscussPage(QueryDiscussmeishituijian query, Integer meishiId) { // LambdaQueryWrapper 避免字符串字段名硬编码 LambdaQueryWrapperDiscussmeishituijian wrapper new LambdaQueryWrapper(); wrapper.eq(Discussmeishituijian::getMeishiId, meishiId) .orderByDesc(Discussmeishituijian::getCreateTime); PageDiscussmeishituijian page this.page(query, wrapper); // 实体转 VO补上用户名、头像等前端展示字段 PageMeishiTuijianVO voPage new Page(page.getCurrent(), page.getSize()); voPage.setTotal(page.getTotal()); voPage.setRecords(page.getRecords().stream() .map(this::toVO) .collect(Collectors.toList())); return voPage; } private MeishiTuijianVO toVO(Discussmeishituijian d) { MeishiTuijianVO vo new MeishiTuijianVO(); BeanUtils.copyProperties(d, vo); // 评论表里只存 user_id用户昵称要关联用户表查 vo.setNickname(userService.getById(d.getUserId()).getNickname()); return vo; }逻辑说明eq 方法拼接的是 meishiId 等于传入参数的查询条件orderByDesc 让最新的评论排在最前面。query 对象直接作为分页参数MyBatis-Plus 的分页拦截器会自动拼接 LIMIT。评论表里一般不会冗余用户昵称只存 user_id查出来后用 stream 逐个关联用户表。数据量小的时候这种写法没问题但如果一条美食推荐有几千条评论逐条查用户表会非常慢。更优的做法是先把整页评论的 user_id 收集成 List再用 userService.listByIds 一次性查出所有用户最后用 Map 匹配把 query 循环改成两步走。接口响应能快一个数量级。4.3 BaiduUtil第三方 API 封装BaiduUtil 是项目里少见的工具类按文件名推断它多半封装了百度地图的逆地理编码或百度翻译接口。它的存在提示了一个容易被忽略的事实毕设项目里所有第三方 API 都应该收敛到工具类里而不是散落在各个 Service 中。public class BaiduUtil { // 百度的 key在百度开放平台申请别写死在代码里 private static final String BAIDU_AK 你的AK; /** * 根据经纬度获取地址描述 * 典型场景用户在景区打卡时前端传坐标后端换成具体地名 */ public static String getAddressByLngLat(String lng, String lat) { try { String url https://api.map.baidu.com/reverse_geocoding/v3/ ?ak BAIDU_AK outputjsoncoordtypewgs84ll location lat , lng; ResponseEntityString resp restTemplate().getForEntity(url, String.class); JSONObject json JSON.parseObject(resp.getBody()); if (0.equals(json.getString(status))) { return json.getJSONObject(result).getString(formatted_address); } } catch (Exception e) { log.error(百度逆地理编码失败, lng{}, lat{}, lng, lat, e); } return null; } private static RestTemplate restTemplate() { // 超时设置 5 秒避免第三方接口拖垮主线程 SimpleClientHttpRequestFactory factory new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(5000); factory.setReadTimeout(5000); return new RestTemplate(factory); } }逻辑说明coordtypewgs84ll 表示传入的是 GPS 原始坐标如果前端用的是百度地图 API 获取的坐标要改成 bd09ll否则地址会偏移几百米。第三方 API 调用必须设置超时时间这里是投喂整个工程最容易被忽视的参数没有超时控制时一旦百度接口网络抖动用户端的请求会一直挂起直到服务器默认的超时时间才报错。另外BAIDU_AK 这类密钥不要直接写在 Java 常量里更不要提交到 GitHub。正确做法是放到 application.yml 里用 Value 注入或者用环境变量。搜索热词里经常出现的 springboot heapdump 敏感信息泄露漏洞就和密钥放在内存里有关后面会专门展开。4.4 MPUtil把琐碎参数解析收拢起来MPUtil 这个名字在 MyBatis-Plus 项目里很常见通常放两个静态方法一个是把前端传的 pageNum/pageSize 转成 Page 对象另一个是拼接模糊查询的通配符。public class MPUtil { /** * 从请求参数里拿分页信息 * params 通常是 Controller 里的 RequestParam Map */ public static T PageT getPage(MapString, Object params) { long current Long.parseLong(String.valueOf(params.getOrDefault(pageNum, 1))); long size Long.parseLong(String.valueOf(params.getOrDefault(pageSize, 10))); return new Page(current, size); } /** * 模糊查询值统一加 %防止调用方漏写 */ public static String likeConcat(String value) { return % (value null ? : value.trim()) %; } }逻辑说明getPage 把「前端可能传 null、可能传字符串数字」这类脏数据收敛到一个方法里Controller 里不再到处写类型转换。likeConcat 则解决的是 MyBatis-Plus 的 like 方法有时需要自己拼 % 的琐碎问题统一加 % 比每个查询都写一遍更不容易漏。这两个方法看起来不起眼但它们暴露了这类项目的一个工程习惯把高频操作抽象成静态工具Service 保持业务逻辑纯净。后面写新模块时分页和模糊查询直接调 MPUtil代码能少写三分之一。5. 上线前的安全加固heapdump 泄露、Swagger 暴露与 curl 验证5.1 heapdump 为什么致命Spring Boot Actuator 是监控利器但如果管理不当就是信息泄露口。/actuator/heapdump端点允许任何人下载当前 JVM 的堆内存快照文件里包含 Redis 地址、数据库密码、用户 token、第三方 API 密钥这些运行时数据。用 MAT 打开堆文件搜索 password 或 token几秒钟就能把应用的敏感配置全部挖出来。这个漏洞在搜索引擎里经常和 springboot 一起出现主要原因就是开发者习惯把 Actuator 原样暴露到公网。5.2 关闭 Actuator 敏感端点如果 pom.xml 里引入了 spring-boot-starter-actuatorapplication.yml 里应该做如下收紧management: endpoints: web: exposure: # 只暴露 health 和 info其余全部隐藏 include: health,info # heapdump、threaddump、env、beans 这些一律关闭 exclude: heapdump,threaddump,env,beans,mappingsinclude 和 exclude 同时配置时exclude 优先级更高。生产环境甚至只需要 health连 info 都可以不放。如果项目里还引入了 Swagger建议同时做两件事一是限制 swagger 只在 dev 环境启用把 springfox.documentation.enabledfalse 写进 application-prod.yml二是给 /swagger-resources/**、/v2/api-docs 这些路径加权限控制不然所有接口定义都会暴露给外部。安全隐患不只是业务数据还有接口结构本身。5.3 curl 实测验证配置改完之后用三组 curl 命令验证效果# 1. 未授权访问 heapdump应返回 404 curl -s -o /dev/null -w %{http_code}\n http://localhost:8080/actuator/heapdump # 2. 不带 token 访问业务接口应返回 401 curl -s -X GET http://localhost:8080/api/jingqu/list?pageNum1\pageSize10 # 3. 带 token 访问正常接口应返回 200 和 JSON 数据 curl -s -H token: 你的token http://localhost:8080/api/jingqu/list?pageNum1\pageSize10第一条命令返回 404 说明 heapdump 端点关闭成功第二条返回 401 说明 AuthorizationInterceptor 在进入 Controller 之前就把请求挡下了第三条返回 200 说明 token 校验链路正常。如果第三条返回 401先确认登录接口有没有排除在拦截器之外再确认 Redis 里 token: 前缀的 key 是否写了同名前缀和校验逻辑里一致。这三个响应码就是上线前最直接的体检结果。本文还有配套的精品资源点击获取