☰
JSP电子书城系统开发实战:从Session购物车到war包部署排错
2026/9/28 1:49:34 网站建设 项目流程

简介:基于JSP的电子书城系统是一套完整项目,采用MVC设计模式,后台用Java与Servlet实现控制层,前端以JSP+JS构建页面,MySQL存储数据,涵盖图书、用户、订单、购物车、分类等核心模块,适合Java Web课程设计、毕业设计或自学练手。项目代码分层清晰:后台使用c3p0连接池及c3p0.properties配置连接数据库,以原生JDBC完成数据交互;service层提供统一接口,util包封装常用工具类;前端采用JSTL c标签与EL表达式遍历后台数据,并用CSS美化页面、抽取公共JS文件提升可读性;数据库使用MySQL、UTF-8编码,通过主外键关联设计表结构。资源共包含587个文件,以Java源码、JSP页面、JS脚本、CSS样式、JAR依赖及SQL数据库脚本为主,并附有Word版参考文档和数据库文件,整体压缩包约10.84MB。目前已有459人学习下载,适合需要快速搭建电子书城项目、完成课程作业或进行二次开发的读者。

1. 为什么 2025 年还有人用 JSP 做电子书城:先别急着喷

看到“基于 JSP 电子书城系统”这个标题,第一反应大概率是“古董”“课设吧”“这年头谁还用 JSP”。说实话,我最初也是这个态度,直到帮人维护过两个跑了好几年的老项目才改观:Spring Boot 前后端分离再香,也架不住某些学校、小公司和政企内网就是有一批 Tomcat + WAR 包的老环境,数据库 Oracle、中间件 Weblogic 都是现成的,你很难说服他们为了一个新书城去动底层。JSP 电子书城真正的价值不在“前沿”,而在“稳”:Java EE 规范里的 HttpSession、Servlet、JDBC 这套东西二十年没大变,资料全、排错容易、招人成本低,做一个能跑、能答辩、能交付的在线书城,JSP 反而是最短路径。

这篇文章不是教你背八股文,而是把一套完整的 JSP 电子书城从表结构、登录态、购物车、订单到部署排错讲清楚。服务端渲染的页面该怎么做、Session 和 Cookie 怎么配合、SQL 注入和 XSS 在这个技术栈里为什么特别高发、打包 war 时哪些坑会让你在部署现场翻车,这些我都会用踩过的坑说话。新手照着把环境搭起来,一步步把功能写出来;熟手可以直接跳到我踩过坑的那几节,对照你自己的维护项目看有没有同样的雷。

2. 环境选型与项目骨架:不是越新越好,而是要能一次跑通

2.1 JDK、Tomcat、IDE 的版本搭配,避开“源发行版 17”这类玄学报错

做 JSP 项目,第一个劝退点不是代码,而是版本。我见过太多人 Eclipse 里建好 Dynamic Web Project,一运行就报“java: 警告: 源发行版 17 需要目标发行版 17”,这多半是 IDE 的编译级别和 Tomcat 自带的 JDK 不一致。JSP 是 Java EE 的老技术,没必要追新 JDK,JDK 8 是兼容性最好的选择——Tomcat 8.5、9 都原生支持,Oracle 和 MySQL 的驱动也都能在 Java 8 下正常跑。如果你电脑里只有 JDK 17 以上,也不是不行,但要记得在 pom.xml 或 IDE 的 Compiler 设置里把 source/target 显式指定为 1.8,同时在 Tomcat 启动参数里把 JAVA_HOME 指到对应的 JDK 路径。

数据库我建议直接上 MySQL 5.7 或 8.0,原因很简单:网上能找到的 JSP 书城示例代码大部分是按 MySQL 写的,驱动用 mysql-connector-java 5.1.49 或 8.0.x,JDBC URL 里的参数我已经踩熟了。如果你一定要用 Oracle,字符集和日期处理的坑会多出不少,对新手不太友好。IDE 选 Eclipse IDE for Enterprise Java and Web Developers 或者 IntelliJ IDEA Ultimate 都行,IDEA 的社区版不带 Java EE 插件,做 JSP 需要自己配置 Facets,我习惯直接用 Ultimate 或 Eclipse,省得在工具上浪费时间。

Tomcat 版本和 Servlet 规范的对应关系是最容易出问题的。JSP 2.3 对应 Servlet 3.1,Tomcat 8.5 和 9 都支持;如果你用 Tomcat 10,包名从 javax.servlet 变成了 jakarta.servlet,老教程里的 import 全部失效,这是升级时最大的坑。我的建议是:新手直接 Tomcat 9.0 + JDK 8 + Eclipse,不用纠结,这套组合跑通率最高。

2.2 Maven 还是传统 WEB-INF/lib?两种项目结构选型

JSP 电子书城这种项目,构建方式有两种主流做法。一种是传统方式,直接把 jar 包扔进 WEB-INF/lib,用 Eclipse 的 Dynamic Web Project 导出 war,这种做法的好处是结构简单、不依赖网络、离线也能编译,缺点是 jar 包冲突和版本管理全凭自觉,同一个项目换个机器可能就编不过。另一种是 Maven 方式,用 pom.xml 管理依赖,代码写完mvn clean package直接出 war,团队协作和持续集成更顺。

我一般会建议用 Maven,哪怕你只是做个课设。原因很实际:JSP 项目常用的依赖就那几个——JSTL、mysql-connector-java、servlet-api、fastjson 或 gson,Maven 能保证所有人拉下来的版本一致,不会出现“在我电脑上能跑”的灵异事件。如果你在 IDEA 里新建项目,选 Maven Archetype 里的 maven-archetype-webapp 就能生成标准 webapp 结构;Eclipse 里则可以右键 New -> Maven Project,选 Packaging 为 war。

Maven 项目的核心结构就是这样:

src/main/java # Java 源码,Servlet、Filter、Service、DAO src/main/resources # 配置文件,如 db.properties、log4j.properties src/main/webapp # 页面文件:JSP、CSS、JS、图片 src/main/webapp/WEB-INF/web.xml pom.xml

注意:不要把 JSP 直接塞到 resources 下,JSP 必须放在 webapp 目录里,否则容器找不到页面,你会得到一个 404。

pom.xml 里最容易踩的坑是 servlet-api 的 scope。很多人把 servlet-api 配成默认的 compile,最后打出来的 war 里同时带了 servlet-api.jar 和 Tomcat 自带的实现,启动时就会出现各种NoClassDefFoundError或方法签名不一致的怪错。servlet-api、jsp-api 这两个依赖必须显式声明为provided,意思是编译时需要,但运行时由 Tomcat 提供。

<dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet.jsp</groupId> <artifactId>javax.servlet.jsp-api</artifactId> <version>2.3.3</version> <scope>provided</scope> </dependency>

这段配置里我把 scope 写成 provided,本质上是告诉 Maven:这个 jar 你在编译和测试的时候给我用,但打包时不要塞进 war。如果你跳过 provided 用了默认 compile,war 包体积会变大,而且部署时大概率出现 jar 包冲突。新手经常忽略这个细节,等到部署现场才炸。

2.3 数据库设计:用户、图书、订单、购物车四张核心表

电子书城的业务逻辑围绕“用户浏览图书 -> 加入购物车 -> 生成订单”这条线展开,数据库表设计至少要覆盖四块:用户表、图书表、订单主表、订单明细表。购物车我建议不建表,直接存在 Session 里,原因后文会细讲。

用户表至少要有 id、username、password、nickname、email、create_time 这几个字段。密码不能明文存,我习惯用 MD5 加盐,哪怕 JSP 是课设级别,这个习惯也别丢。图书表是电子书,跟实体书不一样,没有库存和物流,但要有 book_name、author、price、cover_url、file_url、description、category_id、download_count、create_time。由于是电子书,核心字段是 file_url,指向 PDF 或 EPUB 的实际文件路径;download_count 字段可以做“热门下载”排行榜,这是电子书城和普通网店的一个差异点。

订单表和订单明细表要分开,因为一个订单可能包含多本书。orders 表存 order_id、user_id、total_amount、status、create_time;order_items 表存 id、order_id、book_id、book_name、price、quantity。这样设计的原因是:下单后图书价格或书名如果改了,订单明细里仍然保留购买时快照,否则订单历史里的价格会跟着商品资料变动,这在账务上说不清楚。前后端分离的项目里你还能用 Redis 做优惠券之类的,JSP 项目就别整那么多花活,四张表够用,未来要扩展也能平滑加字段。

SQL 里我建议把字符集、自增主键、时间默认值一次写清楚,省得将来导数据时出乱码:

CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL COMMENT 'MD5加盐后的密文', nickname VARCHAR(50) DEFAULT '', email VARCHAR(100) DEFAULT '', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

utf8mb4 而不是 utf8,这一条能救你命。MySQL 的 utf8 实际只支持三个字节,遇到 emoji 表情或少见汉字直接插入失败,JSP 页面里用户昵称带个 emoji,后台报Incorrect string value,排查一晚上都不知道是字符集问题。utf8mb4 是 utf8 的超集,建议所有表都用它。

图书表里 category_id 做外键关联分类表,但我不会真的在数据库层面加 FOREI GN KEY 约束,原因也很实际:JSP 项目经常要做演示数据导入导出,外键约束会让 delete 和 update 变得特别麻烦。我一般在逻辑层维护关联,也就是 Java 代码里检查分类是否存在,而不是让数据库去管。这个做法在正式企业级项目里可能被 DBA 骂,但 JSP 课设和中小型内部系统里,少一层约束就是少一档子运维负担。

2.4 从零跑通一个“能显示书名列表”的最小 JSP 页面

骨架搭好、数据库建好后,先别急着写登录注册,第一件事是把图书列表在 JSP 页面上渲染出来。这一步跑通了,说明 JDBC、Servlet、JSTL、页面四大环节全部打通,再做功能就是往里添砖。

先写一个最基础的 BookServlet,只负责从数据库查出图书列表,丢到 request 里,转发给 list.jsp:

@WebServlet("/book/list") public class BookListServlet extends HttpServlet { protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { List<Book> books = bookService.queryAllBooks(); req.setAttribute("books", books); req.getRequestDispatcher("/WEB-INF/jsp/book/list.jsp").forward(req, resp); } }

这里的@WebServlet注解从 Servlet 3.0 开始支持,不用在 web.xml 里逐个配 Servlet,代码里写清楚路径即可。注意我用的是forward而不是sendRedirect,因为 forward 是服务端内部跳转,request 里的 books 属性还在;sendRedirect 是浏览器重发一次请求,request 里的东西全没了。把 JSP 放在 WEB-INF 下可以防止用户直接通过 URL 访问 JSP 源码,同时也强制所有页面都经过 Servlet 转发,这对后续做登录拦截很有帮助。

JSP 页面用 JSTL 标签来迭代列表,这是 JSP 项目里最常见的渲染方式。直接在 JSP 里写 Java 代码片段(<% %>)虽然也能跑,但会让页面变得一团糟,而且无法复用。JSTL 的c:forEach帮你把循环这件事抽象成了标签,页面维护起来舒服得多:

<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <table> <tr> <th>书名</th> <th>作者</th> <th>价格</th> <th>操作</th> </tr> <c:forEach items="${books}" var="book"> <tr> <td>${book.bookName}</td> <td>${book.author}</td> <td>${book.price}</td> <td><a href="${pageContext.request.contextPath}/book/detail?id=${book.id}">查看详情</a></td> </tr> </c:forEach> </table>

这个页面上有一个特别值得注意的地方是${pageContext.request.contextPath}。有了它,所有超链接和表单提交路径都会自动带上项目部署名,比如/ebookstore/book/detail?id=1。如果你偷懒直接写/book/detail,而部署的时候 war 包名字改了,或者 Tomcat 里 context path 不是根路径,链接就会 404。这个坑我在帮人排查部署问题时遇到过至少五六次,每次都是本地能跑、放到服务器上就找不到页面。

到这里,最小链路已经通了:浏览器请求/book/list,Servlet 查数据库,JSP 渲染出表格。接下来所有功能都可以在这个骨架上长出来。

3. 登录注册与 Session 登录态:JSP 项目的身份体系怎么搭

3.1 注册时的密码加盐与 MD5 存储

电子书城的第一步是注册。这里我建议用 MD5 加盐而不是单纯的 MD5。原因很简单:光 MD5 的彩虹表已经覆盖了几乎所有常见密码,你把用户密码直接 MD5 存进数据库,等于把密码明文交给了黑客。加盐的做法是,注册时生成一个随机字符串(盐),把“盐+密码”拼接后做 MD5,数据库里同时存盐和密文。校验时再按同样规则拼接计算,比对密文即可。

public static String md5WithSalt(String password, String salt) { String base = salt + password; return DigestUtils.md5Hex(base); } // 注册时 String salt = UUID.randomUUID().toString().replace("-", "").substring(0, 8); String encodedPwd = md5WithSalt(password, salt);

这段代码里,盐取 UUID 前 8 位,每次注册都随机生成,两个人即使密码相同,存下来的密文也不一样。DigestUtils来自 Apache Commons Codec,如果你没用 Maven,就手动引入 commons-codec.jar。密码学上 MD5 已经不够强了,但 JSP 课设和内部系统里,加盐 MD5 比裸 MD5 安全一个量级,而且性能和代码复杂度几乎没增加。想再进一步可以用 SHA-256,代码只需把md5Hex换成sha256Hex。

注册逻辑里还要注意用户名唯一校验。数据库里 username 字段我建了 UNIQUE 约束,但程序层面也要在插入前先查一遍,否则数据库抛 DuplicateKeyException,页面会报 500,体验很糟。更稳的做法是 try-catch 捕获唯一键冲突,然后回显“用户名已被注册”。

3.2 登录时用 Session 保持状态,Filter 统一拦截未登录访问

JSP 项目不像前后端分离那样用 Token,登录状态的常规方案就是 HttpSession。登录成功后把用户对象塞进 Session,后续每个请求都能从 Session 里取出当前用户。Session 的底层是 Cookie 里存一个 JSESSIONID,Tomcat 根据这个 ID 找到对应的 Session 对象。

protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String password = req.getParameter("password"); User user = userService.login(username, password); if (user == null) { req.setAttribute("error", "用户名或密码错误"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } HttpSession session = req.getSession(); session.setAttribute("loginUser", user); resp.sendRedirect(req.getContextPath() + "/book/list"); }

注意req.getSession()不传参数时,如果当前没有 Session 会创建一个新的,登录成功后必须把用户对象存进去,后续页面才能通过${sessionScope.loginUser}取到。跳转用sendRedirect而不是forward——登录成功后要让浏览器重新发起一次请求到图书列表,把地址栏变成/book/list,这样用户刷新页面时不会重复提交表单。

Filter 是 JSP 项目里做登录拦截的标准姿势。在 Filter 里检查请求路径,如果是受保护的资源且 Session 里没有 loginUser,就重定向到登录页。

@WebFilter("/*") public class LoginFilter implements Filter { public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; HttpSession session = req.getSession(false); String uri = req.getRequestURI(); // 白名单:登录页、注册页、静态资源、公共接口 boolean needLogin = !uri.contains("/login") && !uri.contains("/register") && !uri.contains("/static/") && !uri.endsWith(".css") && !uri.endsWith(".js") && !uri.endsWith(".jpg") && !uri.contains("/book/list") && !uri.contains("/book/detail"); if (needLogin && (session == null || session.getAttribute("loginUser") == null)) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } chain.doFilter(request, response); } }

这段代码里我用req.getSession(false)而不是req.getSession(),意思是“如果 Session 不存在就返回 null,而不是创建一个新的”。为什么要这样?因为如果每个未登录用户访问页面都强制创建一个新 Session,服务器内存会被垃圾 Session 塞满,也给了攻击者刷 Session 的机会。白名单的判断方式虽然长得丑,但是直白可维护。实际项目里可以把白名单做成一个 List 或 Set 放在 init 里加载。

这个 Filter 有一个隐藏的坑:如果白名单没把静态资源(CSS、JS、图片)放进去,登录页会变成“裸奔”状态,样式全丢。因为浏览器加载login.jsp里的 CSS 时,会发起一个/static/css/style.css的请求,这个请求也被你的 Filter 拦下来重定向到登录页,CSS 就永远加载不出来。我在第一次写这种拦截器时就栽过,最后发现是*.css被拦截了。上面的代码里我放了三个endsWith判断,就是为了防这个。

3.3 用户权限:普通用户和管理员,要不要搞 RBAC

电子书城的用户权限一般分两档:普通用户和管理员。管理员能上传图书、编辑图书、查看订单列表;普通用户只能浏览、购买、下载。权限模型你可以直接上 RBAC(用户-角色-权限三张表),但这在 JSP 课设里经常是杀鸡用牛刀。我的建议是:用户表加一个 role 字段,默认是 0(普通用户),1 表示管理员。管理员的功能入口在页面上根据 role 判断是否显示,后端 Servlet 里再校验一次 role 即可。

但这里必须说清楚:只做字段判断,也够用了。你是电子书城,不是银行核心,管理员就一两个,没有复杂的权限矩阵需求。做了 RBAC 反而让代码量翻倍,容易出错。不过,如果你是为了毕业设计拿高分,在论文里写“我设计了三层的 RBAC 模型”,那另说——先保证功能跑通,再在纸上画架构图。

后端校验 role 的地方要特别留心一个 URL 越权问题。JSP 项目因为页面是服务端渲染的,很多新手只在页面上把“上传图书”按钮隐藏了,觉得这样就安全了。但实际上,如果上传图书的 Servlet 路径是/admin/book/add,普通用户直接在浏览器地址栏输入这个 URL 一样能访问。所以管理员功能的 Servlet 里,每个请求都必须从 Session 取角色,校验不是 admin 就返回 403 页面:

User user = (User) session.getAttribute("loginUser"); if (user == null || !"admin".equals(user.getRole())) { resp.sendError(HttpServletResponse.SC_FORBIDDEN); return; }

这句话看起来多余,但它是防止水平越权的第一道防线。JSP 项目不像 Spring Security 那样有注解自动拦截,所有权限校验都是手写的,漏掉一个 Servlet 就是漏洞。

4. 购物车与订单:Session 临时数据 vs 数据库持久化,临界点在哪

4.1 购物车放 Session 还是数据库?我为什么倾向 Session

购物车是电子书城最核心的交互模块。实现方案有两条路:一是纯 Session 存储,购物车对象放在 Session 里,用户加入、修改、删除都操作内存对象;二是数据库存储,建 cart 表,用户每次操作都读写数据库。

我的判断是:4 万用户以下、图书这种低频商品,用 Session 完全够。原因有三:第一,购物车是临时性数据,用户今天加入购物车,明天可能就不想要了,没必要持久化;第二,图书没有库存扣减、没有运费计算、没有规格 SKU,购物车模型比电商实物商品简单得多,Session 里放一个 Map 绰绰有余;第三,数据库方案必须考虑购物车合并问题——用户 A 在手机上加了三本书,去电脑登录,购物车还在吗?Session 存储天然做不到跨设备,但如果你的用户场景是单设备使用,这都不是问题。

不过 Session 购物车有一个天然的弊病:只要用户清浏览器 Cookie 或关闭浏览器再开,Session 就没了,购物车也就没了。所以如果你的电子书城预期用户会反复访问,或者你想做“未登录也能加购物车,登录后合并”的功能,那就得上数据库。我的建议是:课设和内部系统用 Session,撑得住现场演示;真正要上线的产品,用数据库表存购物车,而且购物车表设计成user_id + book_id + quantity,业务逻辑更清晰。

Session 购物车的实现可以用一个 Map:

// key: 图书ID, value: 购买数量 Map<Integer, Integer> cart = (Map<Integer, Integer>) session.getAttribute("cart"); if (cart == null) { cart = new HashMap<>(); } String bookId = req.getParameter("bookId"); int id = Integer.parseInt(bookId); cart.put(id, cart.getOrDefault(id, 0) + 1); session.setAttribute("cart", cart);

这段代码里有个细节值得讲:我用了getOrDefault,当用户重复点击“加入购物车”时,数量从 1 变 2,而不是重新 put 把之前的数量覆盖掉。很多新手在这里写cart.put(id, 1),导致每次加购都重置数量,用户点三次还是 1 本,体验极差。还有一个容易踩的坑是Integer.parseInt(bookId)传进来的不是数字会抛 NumberFormatException,所以在 Servlet 里对参数要 try-catch,不能让异常直接抛到页面变成 500。

4.2 购物车页面渲染:从 Session 里的 Map 到图书详情

购物车在 Session 里存的是图书ID -> 数量的映射,但页面上你要展示的是书名、价格、封面。ID 到图书信息的转换,需要查一次数据库。这里有个性能点:不要在购物车页面循环里逐条查数据库,那样有 N 本书就要执行 N 次 SQL。常见的做法是先把所有 ID 取出来,用WHERE id IN (...)一次查出全部图书。

Map<Integer, Integer> cart = (Map<Integer, Integer>) session.getAttribute("cart"); List<Book> books = bookService.queryByIds(cart.keySet()); // 组装成购物车条目 List<CartItem> items = new ArrayList<>(); double total = 0; for (Book book : books) { int qty = cart.get(book.getId()); double subtotal = book.getPrice() * qty; total += subtotal; items.add(new CartItem(book, qty, subtotal)); } req.setAttribute("items", items); req.setAttribute("total", total);

CartItem 可以做成一个普通 POJO,也可以直接在 JSP 里拿 Book 和数量分别取值。用IN查询一次性取回所有图书,看起来只是代码风格问题,但在购物车里有 20 本书时,N+1 查询可能会导致页面响应慢上几百毫秒。JSP 项目的用户量不大,但每一条 SQL 都应该是毫不犹豫的。

购物车页面上的“删除”操作,实际是修改 Session 里的 Map,然后重定向回购物车页面。注意用sendRedirect来实现“防止刷新重复提交”的模式——如果删除后直接 forward,用户刷新页面会再次执行删除逻辑,虽然删除是幂等的,但你可能会在日志里看到一堆重复操作。

4.3 下单的事务边界:订单主表和明细表怎么保证一致性

下单是电子书城最需要保证数据一致性的地方。用户点击“提交订单”时,要写 orders 表和 order_items 表,如果第二张表写入失败而第一张表已提交,数据库里就会出现只有订单、没有商品的孤儿数据。JSP 项目没有 Spring 的@Transactional注解,事务必须手写,踩坑概率极高。

我一般这样写:

Connection conn = null; try { conn = DbUtil.getConnection(); conn.setAutoCommit(false); // 插入订单主表 long orderId = orderDao.insertOrder(conn, order); // 批量插入明细表 for (OrderItem item : order.getItems()) { orderItemDao.insert(conn, orderId, item); } conn.commit(); } catch (Exception e) { if (conn != null) { conn.rollback(); } throw new RuntimeException("下单失败", e); } finally { if (conn != null) { conn.setAutoCommit(true); conn.close(); } }

这段代码有几个细节值得说:conn.setAutoCommit(false)表示手动控制事务,后面每一条 SQL 都在这同一个连接上执行;commit()成功后事务才真正生效;如果中间任何一步抛异常,rollback()会把前面所有操作回滚,保证订单主表和明细表要么同时写入成功,要么同时不写。finally 里把autoCommit设回 true 再 close,是因为很多数据库连接池复用时,如果连接回到了池里还带着 autoCommit=false 的状态,下次拿到这个连接的人就很困惑——这是血泪经验。

另一个细节是 DAO 方法必须接收conn参数,而不是在 DAO 内部自己DbUtil.getConnection()。很多新手下单时报错,就是因为三个 DAO 各自开了三个连接,事务根本控制不了,第一张表提交了,第二张表失败,最后数据对不上。传递同一个 conn,是手写事务的核心。

注意:把订单表和明细表的操作放在一个事务里,这是数据一致性的底线。如果有人告诉你“先插订单,再循环插明细,失败了就删掉订单”,那是在给你挖坑——删除操作可能再次失败,而且并发下会出现窗口期,别人已经查到了这笔残缺订单。

4.4 订单状态机:待支付、已支付、已取消,电子书还要有“已下载”

电子书城订单的状态比实物电商简单,因为没有物流。我常用的一套状态是:0 待支付、1 已支付、2 已取消、3 已完成(已下载)。要明确的是,电子书城没有“已发货”“已签收”,状态流转只有用户付款、用户取消、用户下载这三个动作。

状态在 Java 代码里用常量或者枚举维护,不要散落魔法数字:

public interface OrderStatus { int UNPAID = 0; int PAID = 1; int CANCELED = 2; int COMPLETED = 3; }

这里的重点在于:用户已支付状态下才能看到下载按钮,而且在下载接口里要再次校验订单状态,不能只靠前端隐藏。否则普通用户直接请求/download?bookId=xxx就能把没买的书下载走,那就是严重的越权漏洞。下载时需要校验两件事:这个用户有没有对应的已支付订单,以及这个订单里包含这本书。校验通过后,通过订单 ID 和图书 ID 找到 file_url,把 PDF 文件流写回响应。

还有一个值得讨论的状态:超时未支付自动取消。JSP 项目不引入 Redis 也可以实现——在用户发起支付时记录一个 expire_time 字段,查询订单时 SQL 里加上WHERE status = 0 AND expire_time > NOW()或者反过来判断已过期就显示“已取消”。这种延迟判断的做法简单可靠,比定时任务扫描更省事。定时任务要处理服务器重启、任务堆积、分布式锁等问题,在 JSP 项目里没必要。

5. 电子书文件上传与下载:JSP 场景下的文件处理避坑指南

5.1 用 Servlet 3.1 原生 upload 实现电子书上传,不引第三方库

上传 PDF 或 EPUB 文件是电子书城的功能刚需。Servlet 3.1 之后,javax.servlet.http.Part 接口原生支持 multipart/form-data 文件上传,不需要引入 commons-fileupload。这不仅是代码量减少的问题,也避免了两套文件解析逻辑混用导致的兼容性坑。

<form action="${pageContext.request.contextPath}/admin/book/add" method="post" enctype="multipart/form-data"> <input type="text" name="bookName" /> <input type="text" name="author" /> <input type="file" name="file" /> <button type="submit">上传</button> </form>

关键是 form 上加enctype="multipart/form-data",少了这个属性,Servlet 里取不到 Part,上传永远失败。对应的 Servlet 代码:

Part filePart = req.getPart("file"); String submittedFileName = filePart.getSubmittedFileName(); String ext = submittedFileName.substring(submittedFileName.lastIndexOf(".")); String storedName = UUID.randomUUID().toString() + ext; String savePath = req.getServletContext().getRealPath("/upload") + File.separator + storedName; filePart.write(savePath);

这段代码里我用 UUID 重命名文件,而不是直接用用户上传的文件名。原因有几个:第一,防止文件名包含特殊字符或路径穿越攻击,比如文件名是../../etc/passwd,如果用原始文件名拼接路径,文件会被写到别的目录去;第二,避免同名文件互相覆盖;第三,PDF 文件本身没有辨识度,文件名 UUID 和数据库字段 file_url 对应即可,用户实际看到的下载文件名可以在下载时再指定。getRealPath("/upload")拿到的是 Tomcat 部署目录下的物理路径,需要注意这个目录在 Tomcat 重启或重新部署时会被清空,所以正式项目里要配置虚拟路径映射,把文件存到 Tomcat 之外的磁盘目录,不能依赖/upload这个应用内目录。

5.2 下载时中文文件名乱码和 Content-Disposition 的编码处理

下载电子书时,要让浏览器弹出保存对话框,关键响应头是Content-Disposition: attachment; filename=xxx.pdf。这个头看起来简单,但中文文件名在这里是最容易乱码的。Tomcat 8.5 之后,filename*参数支持 RFC 5987 编码,可以直接用 URLEncoder 处理中文。

resp.setContentType("application/pdf"); String fileName = book.getBookName() + ".pdf"; String encodedName = URLEncoder.encode(fileName, "UTF-8").replace("+", "%20"); resp.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encodedName);

这里我把+替换成%20,因为 URLEncoder 会把空格编码成+,而 HTTP 头里的空格应该编码成%20,否则部分浏览器会把文件名截断。这个坑我帮人查过:文件名带空格时,Chrome 显示正常,IE 下载下来的文件名后缀丢失,排查了半小时才想到是空格编码问题。

下载大文件时,不建议直接把文件读进 byte 数组再一次写出去。几百 MB 的 PDF 会把内存撑爆。标准做法是流式传输,用 InputStream 和 OutputStream 之间做缓冲拷贝:

try (InputStream in = new FileInputStream(file); OutputStream out = resp.getOutputStream()) { byte[] buffer = new byte[8192]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } }

这段代码里我用 try-with-resources 确保流会关闭,这样即使中途抛异常,文件句柄也不会泄漏。JSP 项目里常见的下载翻车现场是:小文件没问题,换了大文件后 Tomcat 运行一会儿就报Too many open files,原因多半是某个流没关。

5.3 上传文件类型校验和大小限制

上传 PDF 时,不能只靠扩展名判断文件类型。一个.pdf扩展名的文件,内容完全可以是一个 exe。在 Servlet 里做两层校验:第一层是扩展名白名单,第二层是读文件头判断魔数。PDF 文件头固定以%PDF-开头,检测这一段字符串基本就够了。

if (!".pdf".equalsIgnoreCase(ext) && !".epub".equalsIgnoreCase(ext)) { throw new RuntimeException("仅支持PDF和EPUB格式"); } InputStream in = filePart.getInputStream(); byte[] head = new byte[5]; in.read(head); String magic = new String(head, StandardCharsets.US_ASCII); if (!magic.startsWith("%PDF-") && !magic.startsWith("EPUB")) { throw new RuntimeException("文件内容与扩展名不符"); }

文件大小限制可以在 web.xml 或 Servlet 3.1 的 MultipartConfig 注解中配置。我一般给上传接口加@MultipartConfig(maxFileSize = 500 * 1024 * 1024, maxRequestSize = 600 * 1024 * 1024),分别限定单个文件 500MB、请求总量 600MB。不配置这个注解的话,Tomcat 默认限制是 50MB,用户传一本超过 50MB 的 PDF 就会直接报错,而且错误信息是 Tomcat 默认的 500 页面,很不友好。在 Servlet 里 catch 住SizeLimitExceededException,回显“文件超过 50MB 限制”,体验会好很多。

5.4 图书封面:两种方案,你选哪种都行

封面图也是 JSP 电子书城必做的。方案一是把图片文件上传到服务器的 upload 目录,JSP 里用<img src="${pageContext.request.contextPath}/upload/xxx.jpg">渲染;方案二是把图片转成 Base64 字符串存数据库,JSP 里用data:image/jpeg;base64,xxx渲染。

我强烈建议用方案一。Base64 存图片的坑很多:每张图体积膨胀约 33%,数据库表变得巨大,备份和导入导出都慢,JSP 页面渲染大 Base64 字符串时还会导致页面 HTML 体积暴涨。方案一唯一的坑是上传目录丢失问题,正如上文说的,临时目录会被清空,所以要做好文件管理。图片缩略图可以用 Java 的 ImageIO 来做,也可以直接不压缩——电子书城封面就是几百 KB 的 JPG,不追求性能极致的话,原图直出完全没问题。

封面路径在数据库里存相对路径,比如/upload/cover/20250501/uuid.jpg,页面渲染时前面拼pageContext.request.contextPath。千万不要存C:\xxx\cover.jpg这种本地绝对路径,换一台机器就全挂了,而且 web 页面根本访问不了本地磁盘路径。这是太多人犯过的错。

6. 部署打包与常见问题排查:war 包部署失败对照手册

6.1 从 IDEA/Eclipse 导出 war 包,别忽略 web.xml 和版本声明

开发环境下用 IDE 直接运行 Tomcat 很顺畅,但你要交付或发布时,就需要打 war 包。传统 Eclipse 项目右键 Export -> WAR file 就能导出;Maven 项目执行mvn clean package,在 target 目录下得到 war。war 包的本质是特定结构的 zip,解开来必须满足WEB-INF/web.xml、WEB-INF/classes、静态资源等在正确的位置,Tomcat 才能识别。

Maven 打包有两个常见问题。一是有些同学把src/main/webapp下的 JSP 页面放错位置,导致打包后 war 里没有页面;二是 web.xml 的版本声明与 Servlet 版本不一致,Tomcat 启动时直接报错。用 Servlet 4.0 时,web.xml 头部长这样:

<?xml version="1.0" encoding="UTF-8"?> <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd" version="4.0"> </web-app>

很多老教程里是http://java.sun.com/xml/ns/javaee这个旧命名空间,Tomcat 9 虽然兼容,但建议直接用新版。如果 web.xml 声明的是 2.5 版本,Tomcat 9 也能跑,但你要用的 Servlet 注解、@MultipartConfig这些特性会失效,因为 2.5 规范里没有注解支持。这个问题最隐蔽的地方在于:Tomcat 可能不报错,只是注解不生效,你会面对“Servlet 明明写了 @WebServlet 却 404”的灵异现象。排查方法很简单,看 Tomcat 启动日志里有没有扫描到你的 Servlet,或者直接删掉 web.xml(Tomcat 9 支持无 web.xml 运行,用注解和 metadata-complete 控制扫描),不过不建议无 web.xml,还是保留它明示版本。

6.2 部署到 Tomcat:context path 与 JDBC 连接的地址坑

war 包放到 Tomcat 的 webapps 目录,启动 Tomcat 会自动解压部署。这时第一个坑就来了:war 包名字决定了 context path。假如你的 war 叫bookstore.war,访问路径就是http://localhost:8080/bookstore/xxx;如果叫ROOT.war,才能直接通过http://localhost:8080/xxx访问。很多同学开发时用的是根路径,部署后链接全 404,因为所有 URL 都少了一个/bookstore前缀。

前面我在 JSP 里一直强调用${pageContext.request.contextPath},就是为了在这个时候兜底。链接里带上下文路径,无论项目部署在根还是子路径,都能自动适配。唯一需要注意的是,如果你在 JS 或 CSS 文件里写了相对路径或绝对路径,比如fetch('/book/list'),部署到子路径时照样 404。这些地方的 URL 也要拼上 contextPath。

第二个坑是 JDBC 连接地址。开发时你写jdbc:mysql://localhost:3306/bookstore,部署到服务器上,数据库往往在另一台机器,或者端口不是 3306。我建议把数据库连接信息放到一个 db.properties 配置文件中,而不是硬编码在 Java 代码里:

jdbc.driver=com.mysql.jdbc.Driver jdbc.url=jdbc:mysql://127.0.0.1:3306/bookstore?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai jdbc.username=root jdbc.password=root

jdbc.url 里的serverTimezone=Asia/Shanghai是 MySQL 8.0 驱动的强制要求,不写经常会报时区错误。useSSL=false是为了避免本地开发没有配置 SSL 证书时报一堆警告。如果你用的是 MySQL 8.0 驱动,driver 类名还要写com.mysql.cj.jdbc.Driver,老驱动类名com.mysql.jdbc.Driver在新驱动里已经废弃,虽然大多数场景下还能用,但日志里会打 warning。

6.3 常见问题与避坑自查清单

做 JSP 电子书城的路上,有五个问题我几乎每次都会遇到,按“现象、原因、解决”写下来,你可以直接当排查手册用。

问题一:JSP 页面中文乱码,页面显示一串问号或方块。

现象:浏览器打开 JSP,中文标题、作者名全变成乱码。

原因:JSP 文件编码与容器解析编码不一致,或者数据库连接没有指定字符集。常见的是 JSP 文件是 UTF-8 编码,但 Tomcat 默认用 ISO-8859-1 解析,或者 MySQL 连接串里少了characterEncoding=utf8。

解决:在 JSP 页面顶部加上<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>,同时确保 JSP 文件本身以 UTF-8 保存;JDBC URL 里加上useUnicode=true&characterEncoding=utf8。三层编码统一,基本不会再乱码。

问题二:登录成功后页面跳转回登录页,循环重定向。

现象:输入正确用户名密码后,浏览器立刻又跳回 login.jsp,有时会提示“重定向次数过多”。

原因:Filter 白名单配置有误,比如登录成功后重定向到/book/list,但这个路径没被放行,Filter 判定未登录,又重定向到 login.jsp,形成循环。

解决:在 Filter 拦截逻辑里,对登录成功后要跳转的页面路径做一次完整检查。更稳的做法是:Filter 里放行一切以/login,/register,/static/,/book/list,/book/detail开头的路径,并且登录成功后重定向到/book/list,此时这条路径已经在白名单里。

问题三:表单提交后刷新,订单重复提交,数据库多出两笔订单。

现象:用户提交订单后,按 F5 刷新页面,浏览器弹出“确认重新提交表单”,确认后订单又生成一笔。

原因:下单请求是 POST,刷新页面时浏览器重新执行了这个 POST。

解决:下单完成后用sendRedirect跳到一个订单成功页面(GET 请求),而不是直接 forward 到成功页面。这样刷新的是 GET 页面,不会重新触发下单。这个模式叫 Post/Redirect/Get,是 Web 开发中最基础也最重要的防重复提交手段。

问题四:Tomcat 启动正常,但访问任何 JSP 都 404,控制台没有任何异常。

现象:项目部署了,地址栏输入http://localhost:8080/bookstore/login.jsp,返回 404。

原因:多半是 JSP 文件没打进 war 包,或者打进了但放在了错误位置。Maven 项目里如果 JSP 放在src/main/resources下,打包后不会进 webapp。

解决:确认src/main/webapp下存在 login.jsp 和 WEB-INF/web.xml,重新打包。把这个检查步骤放在部署前,能省很多时间。

问题五:下载 PDF 时浏览器把文件内容当作页面渲染,页面显示二进制乱码。

现象:点击下载,浏览器不是弹出保存对话框,而是直接打开一个乱码文本页面。

原因:响应头Content-Type设置错了,或者 Servlet 往响应流里写数据时没有设置Content-Disposition: attachment。

解决:下载的 Servlet 里显式设置resp.setContentType("application/pdf")和resp.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encodedName)。attachment 是关键,它告诉浏览器这是下载附件,不要当成页面渲染。

7. 电子书城还可以做什么:检索、预览、下载次数与并发扣减

功能跑通、能部署之后,如果你还想继续投入,这个方向有三件事投入产出比最高。

第一件事是 Lucene 或者简单 SQL LIKE 做图书搜索。电子书城的图书数量通常不大,几万条以内直接WHERE book_name LIKE '%关键词%'加一层 MySQL 全文索引就够。Lucene 的引入会让项目复杂度上升一个量级,除非你要用分词、拼音搜索、搜索热词统计,否则没必要。SQL LIKE 的坑是索引失效——LIKE '%关键词%'无法用普通 B+ 树索引,只能全表扫,但图书表几万行,全表扫也就几十毫秒,对 JSP 项目完全能接受。

第二件事是电子书在线预览。PDF 预览不需要你写 PDF 解析器,方式是在网页里嵌pdf.js或直接用浏览器内置的 PDF 插件。后端只要提供一个能鉴权的 PDF 流式接口,前端用<iframe src="/book/preview?bookId=1">就能预览。这里有个边界要注意:预览接口和下载接口要有不同的权限控制。预览接口通常只允许已登录用户访问,下载接口则必须校验已购买且已支付。如果你把同一个接口既当预览又当下载,用户就能绕过付费直接下载,这是业务漏洞。用 JSP 项目做一个服务端渲染的“在线试读”页面,把 PDF 转成图片分页展示,成本太高,不适合在 JSP 技术栈里做。

第三件事是高并发下的下载计数。如果你运营一个热门电子书,download_count字段在大批量并发下载时会成为性能瓶颈。最简单的改造是把计数放到 Redis 里,但 JSP 项目引 Redis 又重了。一个轻量方案是:下载成功的请求把计数加一操作做成异步,比如在/download接口里先正常响应,再开一条线程去更新数据库。不过这引出了新问题:跨线程的数据库连接管理和异常处理。更稳妥的做法是接受现实——JSP 项目的体量,并发下载峰值可能就是每秒几次,直接UPDATE books SET download_count = download_count + 1 WHERE id = ?也扛得住,不要提前优化。

最后分享一个我的个人习惯:每个 Servlet 里的参数校验都不能省。不管是Integer.parseInt(req.getParameter("id"))还是文件上传的文件名,都要有 try-catch。JSP 项目因为历史包袱,防御式编程比花哨架构更重要。写代码时多一层校验,部署现场就少一通排查电话。希望这篇笔记的避坑清单,能帮你少走几段我走过的弯路。

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

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

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

立即咨询