尧图精选

SSM小说网站实战:从分页优化到权限拦截的完整链路

🕒 发布时间:2026/9/17 19:27:48 📁 来源:尧图网络
简介面向高校计算机专业学生与Java Web初学者这是一份围绕在线小说网站展开的系统设计与实现文档可用于课程设计、毕业设计选题或SSM框架入门实践。全文以Java语言为基础采用Spring、SpringMVC、MyBatis技术栈配合MySQL数据库与Tomcat服务器内容涵盖绪论、技术介绍、需求分析、系统设计等章节并细化用户注册登录、小说分类搜索、在线阅读、评论收藏及管理员审核等模块同时涉及缓存、负载均衡与响应式布局等优化思路。文档附有摘要与目录章节编排清晰便于按需查阅完整项目结构、数据库设计思路与SSM整合流程。压缩包内共1个docx文件包体约3.43MB便于直接打开与二次整理。目前已有153人学习下载适合需要参考同类Java Web项目实现方案的学生与开发者。1. 从一份小说站毕设说起SSM 项目真正的难点在哪很多人拿到基于 Java 的在线小说网站这个题目第一反应是堆 CRUD建表、写增删改查、套几个 JSP 页面就完事。真跑起来才发现卡住项目的从来不是功能数量而是章节表涨到几十万行之后的分页查询、收藏与书架被重复点击写入、以及没登录就能直接访问/admin下的后台地址。这套系统用 Java 写后端Spring SpringMVC MyBatis 做分层MySQL 存数据Tomcat 跑服务开发环境是 MyEclipse——是典型的教学与中小企业项目技术栈。它要解决的问题很具体让读者能按分类和书名找到书、能连续翻章节、能收藏和加入书架让管理员能管分类、公告、轮播、评论和用户。适合正在做 Java Web 课设的人、想补一遍 SSM 完整链路的后端新人以及需要一套可改造成真实业务骨架的开发者。下面按搭建、建模、业务、部署四条线拆开讲。2. SSM 三层骨架搭建依赖、数据源与请求分发2.1 选 SSM 而不是直接上 Spring Boot 的理由这套项目的运行方式是打成 war 包丢进外置 Tomcat而不是内嵌容器跑 jar。这个约束直接决定了技术选型Spring Boot 的自动装配会把数据源、事务管理器、DispatcherServlet 的注册过程全部藏起来出问题时堆栈里只有自动配置类的名字对这个体量的站点反而不好排查。SSM 把这层拆开applicationContext.xml里能看到每个 Bean 是怎么 new 出来的spring-mvc.xml里能看到拦截器和视图解析器怎么注册的mybatis-config.xml和 Mapper XML 里能看到 SQL 是怎么拼的。另一个现实原因是排查能力。Java 后端面试里高频问的 Bean 生命周期、AOP 代理、MyBatis 动态 SQL 与一级二级缓存在这套代码里都有明确的落点Service 层加Transactional就是声明式事务的实例Mapper 接口靠MapperScannerConfigurer扫描注入就是动态代理的实例。把这些位置记住比背概念有用得多。2.2 pom 依赖与 web.xml 的最小可用配置依赖不用贪多SSM 核心就四组Spring 上下文与 MVC、MyBatis 与 spring-mybatis 整合包、MySQL 驱动、Servlet API。版本号用属性统一管理方便整体升降。properties spring.version5.3.31/spring.version mybatis.version3.5.14/mybatis.version mysql.version8.0.33/mysql.version /properties dependencies !-- Spring 核心上下文 MVCwebmvc 已传递依赖 spring-web/core/beans -- dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version${spring.version}/version /dependency !-- 声明式事务依赖 spring-txAOP 织入需要 aspectjweaver -- dependency groupIdorg.springframework/groupId artifactIdspring-jdbc/artifactId version${spring.version}/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.1.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version${mysql.version}/version /dependency !-- 编译期使用打包时必须 provided否则和 Tomcat 自带 servlet-api 冲突 -- dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency /dependenciesscope这一项最容易出问题。javax.servlet-api一旦写成默认的 compilewar 包里就会带上一份 Servlet 接口Tomcat 启动时同一份类被两个类加载器加载轻则ClassCastException重则NoSuchMethodError。同理jsp-api也必须 provided。web.xml只负责三件事注册 Spring 的父容器监听器、注册 DispatcherServlet、把编码过滤器挂上。web-app version4.0 xmlnshttp://xmlns.jcp.org/xml/ns/javaee !-- 父容器加载 Service、Mapper、数据源不加载 Controller -- context-param param-namecontextConfigLocation/param-name param-valueclasspath:applicationContext.xml/param-value /context-param listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener !-- 子容器只加载 Controller、拦截器、视图解析器 -- servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mapping !-- 解决 POST 中文乱码必须放在所有过滤器之前 -- filter filter-nameencoding/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-paramparam-nameencoding/param-nameparam-valueUTF-8/param-value/init-param init-paramparam-nameforceEncoding/param-nameparam-valuetrue/param-value/init-param /filter filter-mapping filter-nameencoding/filter-name url-pattern/*/url-pattern /filter-mapping /web-app父子容器的划分是这套结构的关键。父容器扫com.novel.service、com.novel.mapper子容器扫com.novel.controller如果不做排除Controller 会在两个容器里各被实例化一次注入的 Service 来自不同上下文事务注解直接失效。2.3 数据源、SqlSessionFactory 与事务配置!-- applicationContext.xml 片段 -- context:component-scan base-packagecom.novel !-- Controller 交给 spring-mvc.xml 扫描避免重复注册 -- context:exclude-filter typeannotation expressionorg.springframework.stereotype.Controller/ /context:component-scan bean iddataSource classcom.alibaba.druid.pool.DruidDataSource init-methodinit destroy-methodclose property namedriverClassName valuecom.mysql.cj.jdbc.Driver/ property nameurl valuejdbc:mysql://127.0.0.1:3306/novel?useUnicodetrueamp;characterEncodingutf8amp;serverTimezoneAsia/Shanghaiamp;useSSLfalse/ property nameusername valueroot/ property namepassword value123456/ !-- 连接池参数小说站读多写少初始连接不用大 -- property nameinitialSize value5/ property namemaxActive value30/ property namemaxWait value60000/ property namevalidationQuery valueSELECT 1/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property nametypeAliasesPackage valuecom.novel.entity/ property namemapperLocations valueclasspath:mapper/*.xml/ property nameconfiguration bean classorg.apache.ibatis.session.Configuration !-- 下划线字段自动映射到驼峰属性create_time - createTime -- property namemapUnderscoreToCamelCase valuetrue/ /bean /property /bean !-- 扫描 Mapper 接口生成代理实现注入 Service -- bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.novel.mapper/ property namesqlSessionFactoryBeanName valuesqlSessionFactory/ /bean bean idtxManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean tx:annotation-driven transaction-managertxManager/几个参数值得单独说。mapUnderscoreToCamelCase打开后create_time能自动塞进createTime省掉大量resultMap但如果表里有user_id这类需要映射成userId的字段就刚好映射成别的名字仍需手写 resultMap。url里的serverTimezone不写MySQL 8 驱动在写入datetime时会抛时区异常。maxWait是获取连接的最长等待毫秒数配 60000 表示超时一分钟就抛异常比默认的无限等待更容易定位连接泄漏。2.4 包结构与 Controller 返回约定包路径职责是否被事务管理com.novel.controller接收请求、参数校验、返回视图或 JSON否com.novel.service业务编排、事务边界是com.novel.mapper单表与关联查询接口否com.novel.entity与表对应的实体否com.novel.interceptor登录校验、管理端权限否Controller 的返回值要分两类设计页面跳转返回String视图名前后端交互返回ResponseBody的对象。收藏、加入书架这类操作走 AJAX统一封装成{code, msg, data}三段式前端只用判断code是否等于 200避免每个接口写一套解析逻辑。3. 小说站数据库表设计从章节表膨胀说起3.1 九张表的分工与关联整套库围绕三个核心实体展开小说、章节、用户其余都是围绕它们的关联表。表名作用关键外键xn_novels小说主表含封面、作者、分类、点击、收藏数type_id→xn_type.idxn_chapter章节正文一本书对应 N 行novel_id→xn_novels.idxn_type分类字典无xn_user前台注册用户无t_admin后台管理员与用户表分离无xn_favors收藏关系user_idnovel_idxn_shelf书架关系user_idnovel_idxn_recoms用户推荐记录user_idnovel_idxn_comment评论含管理员回复user_idnovel_id管理员和普通用户分表是这套设计里比较务实的一点后台账号数量少、字段简单和前台用户混在一张表里既要加角色字段又要防越权分开后登录拦截器判断的 Session key 都不一样权限边界更清晰。3.2 建表 SQL修掉原稿里的字段类型问题原稿的表设计里content、chapter_words、create_time全部写成varchar(50)novel_id也用字符串。章节正文用 50 字符根本存不下时间字段存字符串会导致排序和范围查询全部走字符串比较2024-9-1会排在2024-10-1后面。CREATE TABLE xn_novels ( id int(11) NOT NULL AUTO_INCREMENT, source varchar(255) DEFAULT NULL COMMENT 数据来源标识自建为 local, title varchar(100) NOT NULL COMMENT 小说名, summary varchar(500) DEFAULT NULL COMMENT 简介, cover_url varchar(255) DEFAULT NULL COMMENT 封面相对路径, type_id int(11) NOT NULL COMMENT 分类 id, author varchar(50) DEFAULT NULL, status tinyint(1) NOT NULL DEFAULT 1 COMMENT 1 上架 0 下架, favors int(11) NOT NULL DEFAULT 0 COMMENT 收藏数冗余字段, total_words int(11) NOT NULL DEFAULT 0, clicks int(11) NOT NULL DEFAULT 0 COMMENT 点击数, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_type_status (type_id, status), KEY idx_clicks (clicks) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT小说主表; CREATE TABLE xn_chapter ( id int(11) NOT NULL AUTO_INCREMENT, novel_id int(11) NOT NULL COMMENT 所属小说, title varchar(100) NOT NULL COMMENT 章节标题, content longtext NOT NULL COMMENT 正文单章可达数万字, sort_no int(11) NOT NULL DEFAULT 0 COMMENT 章节序号替代按 id 排序, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_novel_sort (novel_id, sort_no), KEY idx_novel (novel_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT章节表;字符集统一用utf8mb4不要用utf8。MySQL 的utf8只有三字节遇到 emoji 或者部分生僻字会直接报Incorrect string value并截断网络小说里这类字符并不罕见。content用longtext最大 4GB单章几万字绰绰有余。sort_no是加进来的字段。只靠自增id排序导入新章节、插入番外、调整卷内顺序都会乱掉有了独立序号加UNIQUE KEY (novel_id, sort_no)还能防止重复导入同一章。favors和clicks是冗余统计字段不这么做的话每次列表页都要对收藏表做count(*)聚合小说一多页面就拖不动。3.3 分页与索引用法列表页的分页不要用LIMIT 起始行, 条数一路翻到底。以章节表为例按novel_id过滤后走idx_novel或唯一键的前缀没问题但小说列表按点击数排序再翻到第 500 页时ORDER BY clicks DESC LIMIT 10000, 20会先扫描并丢弃前 10000 行。-- 慢深分页扫描 10020 行只返回 20 行 SELECT id, title, cover_url, author FROM xn_novels WHERE status 1 ORDER BY clicks DESC LIMIT 10000, 20; -- 快游标分页把上一页最后一条的排序值带过来 SELECT id, title, cover_url, author FROM xn_novels WHERE status 1 AND clicks #{lastClicks} ORDER BY clicks DESC LIMIT 20;游标分页的代价是不能直接跳页只能上一页下一页。小说站的列表页天然就是下一页式浏览接受这个限制换来的性能提升很划算。前端只需要把当前页最后一条的clicks存下来请求时作为参数传回。注意favors和clicks两个冗余字段必须和关联表在同一个事务里更新否则用户取消收藏后favors不减排序结果会和实际数据长期偏离。4. 阅读链路的业务实现分类、搜索、章节翻页与书架4.1 分类导航与列表查询分类页的本质是按type_id过滤 分页 排序。把排序方式做成可选参数用 MyBatis 的choose标签控制排序字段比在 Java 里拼 SQL 字符串安全得多。select idselectByType resultTypeNovelVO SELECT n.id, n.title, n.cover_url, n.author, n.clicks, n.favors, t.type_name FROM xn_novels n LEFT JOIN xn_type t ON t.id n.type_id WHERE n.status 1 if testtypeId ! null AND n.type_id #{typeId} /if choose when testorderBy newORDER BY n.create_time DESC/when when testorderBy favorsORDER BY n.favors DESC/when otherwiseORDER BY n.clicks DESC/otherwise /choose LIMIT #{offset}, #{size} /select#{}是预编译占位符会在 JDBC 层变成?能防止注入${}是字符串直拼只在ORDER BY这种无法参数化的位置才考虑且必须用choose白名单限定取值不能让前端直接传字段名进来。NovelVO是继承了xn_novels实体并多带一个type_name的展示对象放在entity.vo包下不要污染主实体。4.2 搜索LIKE 的边界与替代写法按书名搜索最直接的方式是LIKE CONCAT(%, #{keyword}, %)但前置通配符会让索引失效数据量到十万级就明显变慢。常见的几种处理方式方案适用条件代价LIKE %kw%数据量万级以内全表扫描LIKE kw%只支持前缀匹配命中索引但搜不到书名中间的词全文索引MATCH AGAINST书名和简介一起搜需要建 FULLTEXT 索引中文分词依赖 ngram搜索引擎十万级以上需要分词和高亮多维护一套服务课设和中小站点选第一种完全够用但要给搜索加限制关键词长度不少于 2 个字符结果强制分页同时把status 1写死在 SQL 里避免下架小说被搜出来。如果确定只用书名前缀搜索改成LIKE CONCAT(#{keyword}, %)能直接吃到title上的索引。Service public class NovelServiceImpl implements NovelService { Autowired private NovelMapper novelMapper; Override public PageResultNovelVO search(String keyword, int pageNo, int pageSize) { // 空关键词直接返回空页避免 LIKE %% 全表扫描 if (keyword null || keyword.trim().length() 2) { return PageResult.empty(pageNo, pageSize); } String kw keyword.trim(); int offset (pageNo - 1) * pageSize; long total novelMapper.countByKeyword(kw); ListNovelVO list novelMapper.searchByKeyword(kw, offset, pageSize); return new PageResult(total, list, pageNo, pageSize); } }PageResult封装总数、当前页数据和页码。先查count再查列表是有必要的前端要渲染总页数但两次查询之间可能有数据变动导致总数和列表长度偶尔对不上这是可接受的偏差不要为了强一致去加锁。4.3 章节翻页上一章与下一章怎么取章节阅读页需要当前章正文、上一章 id、下一章 id、目录。常见错误写法是查出全量章节 id 列表再在 Java 里indexOf定位一本书两千章就是两千行数据白读。正确做法是让数据库用一次查询取上下章。-- 上一章取 sort_no 小于当前值中最大的那个 SELECT id, title FROM xn_chapter WHERE novel_id #{novelId} AND sort_no lt; #{sortNo} ORDER BY sort_no DESC LIMIT 1; -- 下一章取 sort_no 大于当前值中最小的那个 SELECT id, title FROM xn_chapter WHERE novel_id #{novelId} AND sort_no gt; #{sortNo} ORDER BY sort_no ASC LIMIT 1;两条 SQL 都命中uk_novel_sort唯一键走索引定位后取一行和章节总数无关。返回null就说明已经到头前端把按钮置灰。正文加载要单独一次查询且不要在SELECT *里带上content去查列表——目录页一次拉几百章正文网络传输和内存都会爆。GetMapping(/chapter/{id}) public String read(PathVariable Integer id, HttpServletRequest request, Model model) { Chapter current chapterService.getById(id); // 含 content if (current null) { return redirect:/404; } Chapter prev chapterService.findPrev(current.getNovelId(), current.getSortNo()); Chapter next chapterService.findNext(current.getNovelId(), current.getSortNo()); // 点击量自增失败不影响阅读放在 try 里避免拖垮主流程 try { novelService.increaseClicks(current.getNovelId()); } catch (Exception ignored) { } model.addAttribute(chapter, current); model.addAttribute(prev, prev); model.addAttribute(next, next); return chapter; }4.4 收藏、书架与点击量的幂等写入收藏和加入书架是两个动作但底层都是用户 - 小说的关系写入。重复点收藏会产生多条记录导致我的收藏页面出现重复书。解决办法是给关系表加联合唯一键插入用INSERT IGNORE或ON DUPLICATE KEY UPDATE。ALTER TABLE xn_favors ADD UNIQUE KEY uk_user_novel (user_id, novel_id);insert idinsertIgnore INSERT IGNORE INTO xn_favors (user_id, novel_id, create_time) VALUES (#{userId}, #{novelId}, NOW()) /insertINSERT IGNORE在遇到唯一键冲突时返回影响行数 0 而不是抛异常Service 层根据返回值判断是新增还是已存在再决定要不要更新xn_novels.favors。收藏数自增必须写成UPDATE xn_novels SET favors favors 1 WHERE id ?不能先SELECT出来加一再写回并发下会丢更新。Transactional(rollbackFor Exception.class) public boolean addFavor(Integer userId, Integer novelId) { int rows favorMapper.insertIgnore(userId, novelId); if (rows 0) { novelMapper.increaseFavors(novelId); // 只在真正新增时加计数 } return true; }rollbackFor Exception.class要显式写。Spring 默认只对RuntimeException回滚MyBatis 抛出的部分异常是受检异常不写这一行会出现关系插入了但计数没更新的脏数据。4.5 评论与回复原稿的xn_comment表只有user_id、novel_id、comment、status管理员回复无处存放。两种改法加parent_id支持层级回复或者加reply_content字段只支持一层管理回复。后者实现简单适合这套系统的规模。ALTER TABLE xn_comment ADD COLUMN reply_content varchar(500) DEFAULT NULL COMMENT 管理员回复 AFTER comment, ADD COLUMN reply_time datetime DEFAULT NULL AFTER reply_content;评论列表按create_time DESC分页未审核的评论用status 0过滤掉管理员后台则按status 0筛出待审列表。评论数不做冗余字段因为详情页只在进入时查一次聚合开销可以接受。5. 登录拦截、部署与线上排错的几个硬技巧5.1 用 HandlerInterceptor 拦住未登录请求Session 判断散落在每个 Controller 里迟早会漏用拦截器统一处理。关键是区分前台和后台前缀后台接口未登录时返回 401 而不是跳转登录页否则 AJAX 请求会拿到一整页 HTML。public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception { Object user req.getSession().getAttribute(loginUser); if (user ! null) { return true; } // AJAX 请求返回状态码页面请求走重定向 if (XMLHttpRequest.equals(req.getHeader(X-Requested-With))) { resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED); resp.setContentType(application/json;charsetUTF-8); resp.getWriter().write({\code\:401,\msg\:\请先登录\}); } else { resp.sendRedirect(req.getContextPath() /user/login); } return false; } }在spring-mvc.xml里注册excludePathPatterns必须放行登录页、注册页、静态资源和小说浏览接口——未登录也应该能看书只有收藏、书架、评论这些写操作需要登录。mvc:interceptors mvc:interceptor mvc:mapping path/**/ mvc:exclude-mapping path/user/login/ mvc:exclude-mapping path/user/register/ mvc:exclude-mapping path/static/**/ mvc:exclude-mapping path/novel/**/ bean classcom.novel.interceptor.LoginInterceptor/ /mvc:interceptor /mvc:interceptors5.2 外置 Tomcat 部署与 JAVA_HOME 配置打包前确认三处pom.xml的packaging是warfinalName不要带中文Servlet API 的 scope 是 provided。mvn clean package之后把target/novel.war丢进TOMCAT_HOME/webapps/启动bin/startup.batLinux 用startup.sh。Tomcat 启动脚本会读JAVA_HOME这个值没配好会直接报JAVA_HOME is not defined correctly。Windows 上在系统环境变量里加JAVA_HOME指向 JDK 根目录不是bin目录再把%JAVA_HOME%\bin追加到PathLinux 上在/etc/profile里导出后source生效。export JAVA_HOME/usr/local/jdk1.8.0_381 export CATALINA_HOME/usr/local/apache-tomcat-9.0.85 export PATH$JAVA_HOME/bin:$PATH # 验证两条命令输出同一个路径才算配对 echo $JAVA_HOME $JAVA_HOME/bin/java -version # 启动并跟踪日志看 Spring 容器有没有完整加载 $CATALINA_HOME/bin/startup.sh tail -f $CATALINA_HOME/logs/catalina.outTomcat 9 用javax.servlet命名空间Tomcat 10 换成了jakarta.servlet同一个 war 包不能混用。如果日志里出现NoClassDefFoundError: javax/servlet/Servlet先确认 Tomcat 主版本再决定降级容器还是升级依赖坐标。5.3 上线后最容易踩的四个坑现象定位位置处理方式中文参数变问号请求头与数据库字符集确认CharacterEncodingFilter在过滤器链首位库和表都是utf8mb4静态资源 404spring-mvc.xmlDispatcherServlet映射/会拦截静态资源加mvc:resources或mvc:default-servlet-handler/事务不生效父子容器与代理方式检查 Service 是否被两个容器重复扫描同类内部方法调用不走代理抽到另一个 Bean时间字段差 8 小时JDBC URL 与 JVM 时区URL 加serverTimezoneAsia/Shanghai容器启动参数加-Duser.timezoneGMT8排查顺序建议从日志开始而不是从代码改起。先看catalina.out里 Spring 容器是否完整启动再看请求进入 Controller 后参数是否正确最后看 MyBatis 打印的 SQL 与参数是否和预期一致——把 Mapper 的logImpl设成STDOUT_LOGGING一行配置就能看到每条 SQL 的实际形态比断点调试快得多。压测时如果发现章节页变慢把xn_chapter的content字段单独拆到一张xn_chapter_content表让目录和列表查询永远不碰大字段这个拆分在小站点后期几乎一定会做。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →