☰
JSP电子书下载系统从零实现:Servlet+JSP+MySQL建表到部署答辩
2026/10/8 3:43:48 网站建设 项目流程

简介:这是一套基于Java/JSP的电子书下载系统毕业设计项目,面向计算机相关专业需要完成Web方向课程设计或毕业设计的在校学生,也适合初学Java Web开发的读者作为完整工程参考。系统前后台功能完整:前台提供电子书分类查询、书名与作者检索、用户注册登录、点券充值、留言板、公告浏览与书籍下载;后台则涵盖个人密码修改、会员信息查询与删除、点券充值审核、图书类别增删改查、电子书信息增删改查以及留言公告管理等模块。资源为RAR压缩包,约89.36MB,内含可直接运行的源码、MySQL 5数据库脚本、演示视频与配套论文,源码经作者亲测可完整运行,项目访问路径为http://localhost:8080/book。目前已有187人学习下载,适合毕业设计答辩演示、课程项目复现,以及希望掌握JSP+Servlet前后台完整开发流程的读者。

1. JSP电子书下载系统:毕业设计选题率最高,但九成实现跑不到答辩

JSP电子书下载系统在 Java Web 学习路径里出现的频率,大概只比学生管理系统低半个身位。它一套系统覆盖数据库增删改查、会话保持、文件上传下载三条主线,业务边界又很清晰,正是两到三周能独立完成、做完还能讲明白的项目。这篇笔记用 Servlet + JSP + MySQL 8.0 的经典架构,把从数据表设计到部署答辩的完整路径捋一遍。第一次写 Java Web 的可以照着做,在 Spring Boot 里泡久了想回补底层的也值得扫。市面上带源码、数据库、视频和论文的电子书下载系统版本五花八门,但核心的表结构和 Servlet 逻辑,最后都能对齐到这套路线上。

2. 架构选型与数据模型:先想清楚表结构,再写第一行 Java 代码

电子书下载系统的业务闭环,用一段话就能说清:用户注册登录,浏览分类、检索书名,点击下载电子书文件;管理员登录后台,上传新书、维护书目、查看下载统计。在这个闭环里,关键决策点只有两个——用不用框架,表怎么建模。这两件事互相咬合:用 Spring Boot + MyBatis Plus,字段能用注解自动建表;用原生 Servlet + JDBC,表结构就得提前用 SQL 敲死。

我推荐后者,理由不是守旧,而是这套系统的体量下,JDBC 能精准定位问题。很多同学引入 mybatis-plus 做这个项目,写 DAO 时觉得简单,一遇到 tb_download 联合索引、自增 ID 和冗余计数字段,就要去翻“根据实体类生成建表 SQL”的工具。本质原因不是工具不行,而是对表结构失去掌控。先把表设计想明白,后面每一行代码都是在给它填数据。

2.1 Servlet + JSP 分层:不引框架,反而是最好的学习路径

这套项目的请求链路短得可怜:JSP 提交表单 → Servlet 接收请求 → DAO 执行 SQL → 结果回传 JSP 渲染。这四条链路是 Java Web 的地基。刻意不用框架,才能把地基打牢。常见做法是 Servlet 只做转发控制,JSP 只负责渲染,DAO 只做数据库增删改查。我在 bookList.jsp 里只用 JSTL + EL 展示列表:

<c:forEach items="${bookList}" var="book"> <tr> <td>${book.bname}</td> <td>${book.author}</td> <td>${book.downcount}</td> <td> <a href="${pageContext.request.contextPath}/DownloadServlet?bid=${book.bid}">下载</a> </td> </tr> </c:forEach>

这个 JSP 片段对应的 Servlet 链路是:从 session 校验登录态,调 BookDao.listBooks(),把结果 setAttribute 后 forward 到 bookList.jsp,一套典型的 MVC。这里最值得记住的是${pageContext.request.contextPath}这个 EL 表达式——它动态拼出项目部署路径,避免写死。很多初版实现直接写<a href="/DownloadServlet">,本地部署在根路径没事,一旦换到http://ip:8080/bookweb/这种上下文路径,浏览器请求的/DownloadServlet根本不存在,点击必然 404。凡是从源码包里抄来的页面,第一件事就是全局搜索替换成这种相对路径写法。

在 Java 端我守一条纪律:Servlet 不写 SQL,DAO 不碰 HttpSession。这套分层在答辩时帮过大忙——被问“以后要换数据库怎么办”,答案就是改 DBUtil 连接串,页面和 Servlet 一行不动。事务控制在 DAO 层用 try-catch 包住 Connection 做 commit/rollback,而不是在 JSP 里写脚本片段。如果 JSP 页面里出现了大段<% %>Java 代码,说明实现方式还停留在 jsp 当 Servlet 用的阶段,该拆的拆、该合的合。

2.2 七张表而不是五张:下载记录表才是系统的灵魂

很多初版实现只有三张表:用户、图书、分类。勉强能跑,但一答辩就露怯——“热门书目排行怎么来的?”“用户行为数据在哪?”一个号称电子书下载的系统,没有下载行为记录,等于业务数据没有沉淀。我常用的表结构是七张:

表名作用核心字段
tb_user前台注册用户uid, uname, upwd, email, regtime
tb_admin后台管理员aid, aname, apwd, last_login
tb_category电子书分类cid, cname, cdesc
tb_book电子书元信息bid, bname, author, cid, filesize, filepath, cover, downcount, uploadtime
tb_download下载行为记录id, uid, bid, down_time, ip
tb_favorite用户收藏id, uid, bid, add_time
tb_comment用户评论id, bid, uid, content, comment_time

tb_download 是我反复强调的一张表。它把一次点击变成一条可查记录,于是“热门书目 TOP10”“每日下载量”都变成一行 GROUP BY SQL,而不是拍脑袋填一个数。tb_favorite 和 tb_comment 看起来是加分项,但它们直接决定了论文里的功能模块图能不能画满三层——没有这两张表,你的系统就只有“用户 + 书目 + 下载”三个平面,显得单薄。

建表语句以 tb_book 为例:

CREATE TABLE tb_book ( bid INT PRIMARY KEY AUTO_INCREMENT, bname VARCHAR(200) NOT NULL COMMENT '书名', author VARCHAR(100) DEFAULT '' COMMENT '作者', cid INT NOT NULL COMMENT '分类ID', filesize BIGINT DEFAULT 0 COMMENT '文件大小,单位字节', filepath VARCHAR(300) DEFAULT '' COMMENT '存储相对路径', cover VARCHAR(300) DEFAULT '' COMMENT '封面图路径', downcount INT DEFAULT 0 COMMENT '下载次数', uploadtime DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间', KEY idx_cid (cid), KEY idx_bname (bname) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这个建表语句里有三个参数要说明。第一,filesize 用 BIGINT 不用 INT,因为电子书合集单文件超过 2GB 并不罕见,INT 上限约 21 亿字节、也就是 2GB,超出会报错或截断。第二,downcount 是冗余计数,为了列表页快速展示,真实统计以 tb_download 为准。第三,charset 用 utf8mb4 而不是 utf8——MySQL 的 utf8 实际是 utf8mb3,不服从于完整 Unicode,书名里出现生僻字会写入报错或存成问号。这属于典型的“看着能用、一踩就翻车”的细节。

2.3 物理外键不建,索引不能省

上面建表语句里我没有写 FOREIGN KEY 子句。原因不是反对数据一致性,而是物理外键在实际维护里容易被“绑架”:删除分类前必须保证分类下没有书,否则 SQL 直接报外键约束异常。这类异常在演示现场出现一次,观感很差。

常见做法是保留逻辑外键关系——表结构里 cid 的语义天然关联 tb_category,但约束在 DAO 层用代码保证。比如删除分类前先查书目数量:

public boolean deleteCategory(int cid) { String check = "SELECT COUNT(*) FROM tb_book WHERE cid=?"; String delete = "DELETE FROM tb_category WHERE cid=?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(check)) { ps.setInt(1, cid); ResultSet rs = ps.executeQuery(); if (rs.next() && rs.getInt(1) > 0) { return false; // 分类下还有书,拒绝删除 } try (PreparedStatement ps2 = conn.prepareStatement(delete)) { ps2.setInt(1, cid); return ps2.executeUpdate() > 0; } } catch (SQLException e) { e.printStackTrace(); return false; } }

索引设计上,至少建两列组合索引:

ALTER TABLE tb_download ADD KEY idx_bid_time (bid, down_time);

组合索引的列顺序有讲究:把等值过滤的 bid 放在前,范围过滤的 down_time 放在后。反过来写,索引在 WHERE bid=? AND down_time BETWEEN ? AND ? 的场景下只能走单列,效率差一个量级。这类细节在论文的数据库设计章节里写一句,评审印象分会明显不一样。

3. 可运行的工程落地:登录、上传、下载的 Servlet 实现与部署步骤

到了这一章,假设你已经建好数据库、导入了七张表。接下来要做的是把项目跑起来,重点看三个核心链路的编码:登录会话控制、文件上传、文件下载。这三段代码覆盖了 JSP 项目里最容易出问题的三类坑,也是答辩时评委最爱追问的地方。

3.1 环境准备:JDK 8 + Tomcat 9 + MySQL 8.0 最小配置

环境版本先钉死。JDK 1.8 或 JDK 11 都行,项目不用新版本语法;Tomcat 9.0 对应 Servlet 4.0 规范;MySQL 8.0。注意 MySQL 8.0 的 JDBC 驱动类是com.mysql.cj.jdbc.Driver,不是老版本的com.mysql.jdbc.Driver——很多源码包是从 MySQL 5.x 时代流传下来的,驱动不换,连库必挂。

数据库连接工具类放到 com.example.util 包下:

public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/bookdb" + "?useUnicode=true&characterEncoding=utf8" + "&serverTimezone=Asia/Shanghai&useSSL=false"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }

三个连接参数逐个说明:useUnicode=true&characterEncoding=utf8保证从 JDBC 写入数据库的字符串按 UTF-8 编码,和表结构的 utf8mb4 配套;serverTimezone=Asia/Shanghai是因为 MySQL 8.0 默认取服务器时区,国内机器不指定会报 CST 约等于服务器时区错误;useSSL=false关掉本地开发的 SSL 告警。数据库脚本导入用一条命令即可:

mysql -u root -p < bookdb.sql

导入后立刻验证表数目:SHOW TABLES;看到七张表再进下一步。很多部署翻车都和导入不完整有关,后面表对不上,代码一跑就爆空指针。

3.2 登录注册与 Filter 权限拦截:防绕过写法

用户登录的核心逻辑就是一个 SQL 查询加一个会话写入。我常用的 LoginServlet 写法:

protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); String uname = request.getParameter("uname"); String upwd = request.getParameter("upwd"); UserDao dao = new UserDao(); User user = dao.findByUname(uname); if (user != null && user.getUpwd().equals(MD5Util.md5(upwd))) { HttpSession session = request.getSession(); session.setAttribute("user", user); response.sendRedirect(request.getContextPath() + "/bookList"); } else { request.setAttribute("loginError", "用户名或密码错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); } }

两个细节值得说明。第一,密码不能明文存表,MD5 在当前安全性已经不够,但至少不是明文;更正常的做法是加盐哈希,比如 SHA-256 或 BCrypt,这套代码后续可以平滑替换。第二,登录成功用sendRedirect而不是forward,因为重定向会让浏览器地址栏变成目标 URL,刷新页面不会重复提交表单;如果用 forward,刷新一次就重复登录一次。

权限控制的关键不在 LoginServlet 内部,而在 Filter 层:

public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; HttpSession session = request.getSession(false); String uri = request.getRequestURI(); // 白名单:登录页、登录接口、注册接口、静态资源 if (uri.endsWith("/login.jsp") || uri.endsWith("/LoginServlet") || uri.endsWith("/RegisterServlet") || uri.contains("/static/")) { chain.doFilter(request, response); return; } if (session != null && session.getAttribute("user") != null) { chain.doFilter(request, response); } else { response.sendRedirect(request.getContextPath() + "/login.jsp"); } }

白名单写法比黑名单靠谱。黑名单是“拦截已知危险”,新加一个页面忘了列入黑名单就漏了;白名单默认拦截一切,只有明确放行的页面能绕过。想细分管理员权限,可以在 Filter 里用request.getRequestURI().startsWith("/admin/")再判断一次 session 里的角色字段。

3.3 电子书上传:multipart 表单与文件路径分离

书籍上传是管理员后台的核心功能。JSP 表单有两个关键点:enctype="multipart/form-data"必须写,否则request.getParameter拿到的是 null;文件选择控件要限制可接收类型。

<form action="${pageContext.request.contextPath}/UploadServlet" method="post" enctype="multipart/form-data"> <input type="text" name="bname" required> <select name="cid"> <option value="1">计算机</option> <option value="2">文学</option> </select> <input type="file" name="file" accept=".pdf,.epub,.mobi"> <button type="submit">上传</button> </form>

Servlet 端接收 part:

@MultipartConfig( maxFileSize = 1024 * 1024 * 200, // 单文件最大 200MB maxRequestSize = 1024 * 1024 * 250, // 整个请求最大 250MB fileSizeThreshold = 1024 * 1024 // 超过 1MB 写入临时文件 ) public class UploadServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); Part part = request.getPart("file"); String originalName = part.getSubmittedFileName(); if (!originalName.endsWith(".pdf") && !originalName.endsWith(".epub")) { request.setAttribute("uploadError", "只支持 PDF/EPUB 格式"); request.getRequestDispatcher("/admin/upload.jsp").forward(request, response); return; } String uuidName = UUID.randomUUID().toString() + "_" + originalName; part.write("/data/ebooks/" + uuidName); // 插入 tb_book 表:bname, cid, filepath="/data/ebooks/" + uuidName, filesize=part.getSize() } }

@MultipartConfig的三个参数是上传功能的护身符:maxFileSize 控制单文件上限,maxRequestSize 控制整个 multipart 请求体上限,fileSizeThreshold 表示超过 1MB 的文件先落临时文件再转存——防止大文件把内存打爆。part.write 的目标路径必须是配置文件里外置出来的,不能写进项目 webapp 目录。这里存的是完整绝对路径,也可以只存相对路径、下载时再拼配置前缀,后面的避坑章节会展开讲。

3.4 电子书下载:Content-Disposition 与中文文件名

下载是整系统的核心动作,也是坑位最多的一段逻辑。我常用的 DownloadServlet 实现:

protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { int bid = Integer.parseInt(request.getParameter("bid")); BookDao dao = new BookDao(); Book book = dao.findById(bid); if (book == null) { response.sendError(404, "book not found"); return; } dao.increaseDownCount(bid); File f = new File(book.getFilepath()); if (!f.exists()) { response.sendError(404, "file missing"); return; } response.setContentType("application/octet-stream"); String encodedName = URLEncoder.encode(book.getBname(), "UTF-8").replace("+", "%20"); response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encodedName); try (InputStream in = new BufferedInputStream(new FileInputStream(f)); OutputStream out = response.getOutputStream()) { byte[] buf = new byte[4096]; int len; while ((len = in.read(buf)) != -1) { out.write(buf, 0, len); } } }

三个参数说明。第一,filename*=UTF-8''是 RFC 5987 标准写法,比老的filename=更可靠,支持中文和文件名里的空格;URLEncoder.encode会把空格编码成+,所以要.replace("+", "%20")兜底。第二,Content-Disposition必须放在setContentType之后,顺序反了某些浏览器会忽略。第三,try-with-resources保证输入输出流一定被关闭,否则 Tomcat 会一直占用文件句柄,下载几次之后文件就被锁住;不用 try-with-resources 的话,finally 里也要显式 close。

4. 高频踩坑与排查记录:从 404 到中文乱码再到下载文件损坏

这一章是血泪经验汇总。这类 JSP 项目能跑到答辩的少,倒不是功能做不出来,而是被几个经典小坑卡到心态崩。每条按“现象 → 原因 → 解决”写,照着排查即可。

4.1 点击下载 404:路径前缀和部署目录两处最容易犯错

现象:本地开发一切正常,war 包部署到服务器后,点“下载”按钮报 404;JSP 页面上的静态图片也经常打不开。

原因:页面里写了硬编码绝对路径,比如/DownloadServlet。本地部署成根路径时没问题,一旦部署到http://ip:8080/bookweb/上下文,浏览器请求的还是/DownloadServlet,应用前缀bookweb丢了,自然 404。另一个常见原因是 JSP 文件放在 WEB-INF 外直接访问没问题,但相对路径会随目录层级变化而失效。

解决:全局搜索<a href="/、action="/这类硬编码,统一替换成${pageContext.request.contextPath}/前缀。静态资源用<c:url>生成 URL 也行,效果一样。替换完重启,再逐个页面点一遍,路径类的 404 基本清零。

4.2 中文乱码:GET 和 POST 是两个独立战场

现象:注册用户名含中文,入库后变成三个问号;搜索框输入中文查不到结果。

原因:POST 请求体默认按 ISO-8859-1 解码,数据库按 UTF-8 存取,两头编码对不上;GET 参数走 URI 解析,由 Tomcat 的 URIEncoding 控制,老版本默认不是 UTF-8。

解决:在读取任何参数之前执行request.setCharacterEncoding("UTF-8"),放在 Filter 里统一处理更好;server.xml 的 Connector 加URIEncoding="UTF-8";JDBC 连接串带characterEncoding=utf8;JSP 页面顶部声明<%@ page contentType="text/html;charset=UTF-8" %>。四条同时满足,中文问题才能根治,少一条都可能偶发。

4.3 下载的 PDF 打不开:JSP 白空格混进响应流

现象:下载下来的 PDF 比原文件大几千字节,打开提示文件损坏。

原因:下载逻辑写在 JSP 页面里,页面前面的换行和空格被 Tomcat 当成响应内容输出到流,破坏了二进制文件。这类写法在很早的源码包里很常见,原封不动搬到 Servlet 里就没事了。

解决:下载逻辑全部收进 Servlet 的 doGet,JSP 页面不做下载响应;Servlet 里写响应之前不要输出任何内容,注释也不要写;流用 try-with-resources 包住,确保 flush 及时。

4.4 上传 20MB 文件报 500:maxPostSize 的默认限制

现象:小文件能传,大文件报 500 或控制台报 maxPostSize exceeded。

原因:Tomcat 对 POST 请求体大小有默认上限,multipart 表单整个请求体超过直接拒绝;@MultipartConfig若没配 maxFileSize 也有默认限制,两边夹击,20MB 的 PDF 大概率被卡。

解决:server.xml 中该 Connector 设置maxPostSize="0",0 表示不限制。同时@MultipartConfig显式声明 maxFileSize 和 maxRequestSize,和上传功能的设计值对齐。改完重启 Tomcat 生效,不重启等于白改。

4.5 上传文件重启就丢:别把文件写进 webapps 目录

现象:上传成功,Tomcat 重启后所有历史书名还在,但点下载 404;重新部署过更严重,整个上传目录消失。

原因:文件被写进了 webapps 下的项目目录。Tomcat 执行 clean、重新部署时会清空项目目录,文件自然没了。另一种情况是数据库存了本机绝对路径,换台机器或者换操作系统,路径直接失效。

解决:文件写到项目外部固定目录,比如/data/ebooks/(Linux)或D:/ebooks/(Windows);数据库 filepath 字段存相对规范化的路径或绝对路径均可,但必须保证路径由统一配置项拼出来,方便换环境时只改一处。

4.6 下载次数不涨:浏览器缓存把请求吃了

现象:连续点下载十次,tb_book.downcount 只涨一两次。

原因:浏览器对同一 URL 的响应做了缓存,后续点击直接从本地缓存读,请求根本没到服务器。下载响应没有设置禁用缓存的响应头,是默认行为。

解决:DownloadServlet 里加两行响应头:Cache-Control: no-store, no-cache, must-revalidate和Pragma: no-cache。但这里要留意,缓存本身对服务器是保护,业务统计的真实数据在 tb_download 表中逐条记录,不受缓存影响,downcount 只是展示用的冗余计数。排查时对比两个数值就能确认是缓存,而不是计数逻辑写错。

5. 答辩现场与上线前检查:四个让系统看起来像生产级的细节

5.1 准备充足预置数据

答辩前把七张表灌上足量数据:书目至少三千条,用户一百个,tb_download 里塞满两周的下载记录。没有数据支撑,评委随便输个关键词查不到结果,场面会很冷。预置数据别用手工 insert,写一个 Java 批处理或 SQL 存储过程循环生成,尽量让书名、作者分布有真实感。

5.2 用 curl 验证下载和断点续传

上线前我用 curl 做三个验证:响应头、断点、缓存头。

curl -I "http://localhost:8080/bookweb/DownloadServlet?bid=1" curl -H "Range: bytes=0-1023" -o test.bin "http://localhost:8080/bookweb/DownloadServlet?bid=1" curl -I -H "Cache-Control: no-cache" "http://localhost:8080/bookweb/DownloadServlet?bid=1"

第一条看 Content-Disposition 和 Content-Length 是否正常;第二条验证 Range 请求能不能返回 206 和正确的片段;第三条确认响应里带上了 no-cache。这三条跑通,下载模块的底层就稳了。

5.3 导出最新的 SQL 脚本

带论文的源码包里的数据库脚本,经常是作者早期版本的快照,和你后来改的表结构对不上。答辩前重新mysqldump一份当前库,替换掉原来的 bookdb.sql。这是最容易疏忽的事——代码是对的,但别人拿旧脚本跑不出你的效果。

5.4 论文用法

论文里的“系统功能测试”一章,直接把 tb_download 表的统计结果截图放进去。热门书目排行、每日下载趋势这些图,都是数据自然长出来的,比手画表格更有说服力。平时有导出数据的习惯,这个工作十分钟就能搞定。

这些年做过的 Java Web 项目,返工率最高的永远是这类 JSP 作业题——不是功能难,而是环境、路径、编码、IO 流这些基本功在悄悄要命。希望这篇笔记能帮你把坑提前踩平,到答辩那天稳稳当当把项目讲完,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询