简介:一份JavaWeb网上购物书城课程设计/期末大作业完整资料包,面向计算机相关专业在校学生、教师及企业员工,尤其适合需要完成课设、毕设或项目初期演示的入门与进阶学习者。压缩包共140个文件,以44个JSP页面与12个Java类构成核心业务逻辑,涵盖29个class编译文件、14个JS脚本、12个HTML页面、8个CSS样式表及8个XML配置,另含数据库SQL脚本、jar依赖与说明文档,整体仅2.28MB,结构清晰便于导入运行。资料包含商城前台购物、后台商品管理、订单处理等模块,代码均测试通过,功能稳定,可在其基础上进行二次修改实现更多功能,并附有运行相关说明,适合边学边练。目前已有391人学习下载,是快速完成课程设计或期末大作业的高质量参考。
1. 网上购物书城的 JavaWeb 数据库课设:先想清楚「能跑」和「能答辩」的差距
网上购物书城是 JavaWeb 数据库课程设计里出现频率最高的选题,几乎每个班都有几个人做它。这个选题的典型构成是:MySQL 数据库配合 SQL 脚本,后端用 Servlet + JSP + JDBC 实现登录注册、图书分页、购物车、生成订单这些增删改查,最后配一份说明文档和一个能在 IDEA 里直接跑起来的项目。很多人以为这份课设的重点是「让代码跑起来」,但答辩现场真正拉开分数差距的是数据库表结构设计、订单事务处理和 SQL 注入这几个点——这些恰好是课程考核大纲里「数据库设计能力」的落点。本篇按我本人做课设代跑、帮同学救火的经验,把从建库到能答辩的完整路径拆开讲。
2. 数据库设计先行:书城项目的表结构、关系与 SQL 脚本落地
在写第一行 Java 代码之前,先把数据库的五张表设计清楚:用户表 user、图书表 book、订单表 orders、订单明细表 order_item、购物车表 cart。三张主表承载业务实体,两张关联表解决「用户 - 订单 - 商品」之间的多对多关系。ER 关系上,一个 user 对应多个 orders,一个 orders 对应多个 order_item,order_item 关联 book 记录下单时的商品快照。这里关键词是「快照」:课设里最容易翻车的地方,就是订单明细直接关联图书表,图书价格一变,历史订单金额跟着变。正确做法是把下单时的书名、单价、数量冗余存进 order_item,哪怕 book 表后续改价,历史订单依然保持不可变。
购物车表的设计则要看粒度。选择持久化购物车(每次加购写库)的优势是用户换浏览器购物车不丢,缺点是代码量多一套增删改查;选择 Session 购物车则少两张 DAO,但浏览器一关购物车就没了。课程设计按「好答辩」的标准,持久化购物车更有讨论价值——老师追问「购物车数据要不要入库」时,你能说出取舍逻辑,这就不是白做的功能。
2.1 用户、图书、订单三张核心表:字段、类型、默认值
我建表时习惯把用户表、图书表、订单表的字段一次定到位。因为项目中期补字段要改 DAO 和 JSP,还要处理已有数据的默认值,成本远高于建表时多犹豫五分钟。MySQL 里ALTER TABLE加字段看着简单,实际涉及存量数据的填充策略,课设阶段改起来非常痛苦。
用户表 user 字段不宜多,但也不该只留 id 和 username。一份能上台面的课设至少要包含 id、username、password、nickname、phone、create_time。password 不要存明文,至少用 MD5 摘要存储,登录校验也走 MD5 后比对。字段长度上,username 给 VARCHAR(32),password 给 VARCHAR(64)——MD5 摘要固定 32 位,但给到 64 位是给未来切 BCrypt 留余地,这个细节在答辩讲「安全性考虑」时能提一句,是加分项。
图书表 book 的金额字段必须用DECIMAL(10,2),Java 侧用 BigDecimal 接收。float 和 double 存在二进制浮点误差,金额做累计求和时容易出现 0.1 + 0.2 不等于 0.3 的诡异结果。这个知识点可以直接决定答辩时「价格为什么不用 double」这个问题能不能答上来。订单表 orders 和明细表 order_item 是整套业务的核心,orders 要记清楚 who(user_id)、when(create_time)、how much(total_amount)、what state(status)。status 用 TINYINT 存状态码:1 待支付、2 已支付、3 已发货、4 已完成、5 已取消。只存码值,显示层做映射,不要在数据库字段里存「待支付」这种中文。
下面是可以直接执行的建表 SQL,按依赖顺序从上到下执行即可。
-- 创建书城数据库,指定 utf8mb4 字符集 CREATE DATABASE IF NOT EXISTS bookstore DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE bookstore; -- 用户表 CREATE TABLE `user` ( `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', `username` VARCHAR(32) NOT NULL COMMENT '登录名', `password` VARCHAR(64) NOT NULL COMMENT '密码(MD5摘要)', `nickname` VARCHAR(50) DEFAULT NULL COMMENT '昵称', `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';这段 SQL 有两个容易忽略的点。一是DEFAULT CHARSET必须指定 utf8mb4 而不是 utf8——utf8mb4 才是 MySQL 8 下完整支持中文和特殊字符的字符集,很多课设项目中文乱码的根子,就是建库时用了默认 Latin1 或者旧版 utf8。二是 username 加 UNIQUE KEY,注册功能里要捕获这个唯一索引抛出的重复键异常并提示用户,而不是等到查询时才发现数据不对。
继续建订单、明细和图书表:
-- 图书表 CREATE TABLE `book` ( `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', `title` VARCHAR(100) NOT NULL COMMENT '书名', `author` VARCHAR(50) DEFAULT NULL COMMENT '作者', `publisher` VARCHAR(50) DEFAULT NULL COMMENT '出版社', `price` DECIMAL(10,2) NOT NULL COMMENT '定价', `stock` INT NOT NULL DEFAULT 0 COMMENT '库存', `cover` VARCHAR(255) DEFAULT NULL COMMENT '封面图片路径', `description` VARCHAR(500) DEFAULT NULL COMMENT '简介', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '上架时间', PRIMARY KEY (`id`), KEY `idx_title` (`title`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书表'; -- 订单表 CREATE TABLE `orders` ( `id` INT NOT NULL AUTO_INCREMENT COMMENT '订单主键', `order_no` VARCHAR(32) NOT NULL COMMENT '订单号', `user_id` INT NOT NULL COMMENT '下单用户ID', `total_amount` DECIMAL(10,2) NOT NULL COMMENT '订单总金额', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1待支付 2已支付 3已发货 4已完成 5已取消', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`), CONSTRAINT `fk_orders_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表'; -- 订单明细表 CREATE TABLE `order_item` ( `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', `order_id` INT NOT NULL COMMENT '所属订单ID', `book_id` INT NOT NULL COMMENT '图书ID', `book_title` VARCHAR(100) NOT NULL COMMENT '下单时书名快照', `price` DECIMAL(10,2) NOT NULL COMMENT '下单时单价快照', `quantity` INT NOT NULL DEFAULT 1 COMMENT '购买数量', `subtotal` DECIMAL(10,2) NOT NULL COMMENT '小计金额', PRIMARY KEY (`id`), KEY `idx_order_id` (`order_id`), CONSTRAINT `fk_item_order` FOREIGN KEY (`order_id`) REFERENCES `orders` (`id`), CONSTRAINT `fk_item_book` FOREIGN KEY (`book_id`) REFERENCES `book` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';order_item 里冗余 book_title 和 price 这一处,答辩时是实打实的加分项。老师追问「明细表为什么不通过 book_id 去 join 查询书名和价格」,回答思路是:下单之后图书可能改名、下架、调价,如果只存 book_id,历史订单渲染会失真;冗余快照字段是为了保证订单的不可变性。orders 表的 order_no 用唯一索引而非主键,主键保留自增 id。订单号生成规则建议用时间戳 + 用户 ID + 随机数,形如20250108120000_3_482,不直接用主键当订单号的原因是防止枚举遍历——订单号被猜出来以后,可以顺着接口爬数据。
字段类型的选择可以整理成一张参数表,放进文档说明里很加分:
| 字段类型 | 适用字段 | 选型理由 |
|---|---|---|
| DECIMAL(10,2) | price、total_amount、subtotal | 避免浮点误差,精度可控 |
| TINYINT | orders.status | 状态码值化,扩展新状态不用改结构 |
| VARCHAR(64) | password | 兼容 MD5(32) 与 BCrypt(60) 两种摘要长度 |
| DATETIME | create_time | 配合 DEFAULT CURRENT_TIMESTAMP,时间交给数据库 |
| INT + UNIQUE | order_no、username | 业务唯一键与物理主键分离,防止遍历 |
2.2 购物车表、模拟数据与 SQL 脚本的组织方式
购物车表 cart 我一般按三个字段加一个时间戳设计:id、user_id、book_id、quantity、add_time。user_id 和 book_id 加联合唯一索引,保证同一个用户对同一本图书只有一条记录。加购时先尝试INSERT ... ON DUPLICATE KEY UPDATE quantity = quantity + 1,逻辑上比「先查再决定是 Insert 还是 Update」少一次 SQL 往返。
-- 购物车表 CREATE TABLE `cart` ( `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', `user_id` INT NOT NULL COMMENT '用户ID', `book_id` INT NOT NULL COMMENT '图书ID', `quantity` INT NOT NULL DEFAULT 1 COMMENT '数量', `add_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_book` (`user_id`, `book_id`), CONSTRAINT `fk_cart_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`), CONSTRAINT `fk_cart_book` FOREIGN KEY (`book_id`) REFERENCES `book` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='购物车表';执行顺序要先建 user 和 book,再建 orders 和 cart,最后建 order_item。外键约束要求被引用的表先存在,顺序错了会报「无法添加外键约束」。解决方式是建表脚本从上到下按依赖顺序排好,全选执行,一次性过。
模拟数据是课设里容易被低估的一环。数据库只有 3 条图书记录,分页功能根本展示不出来。我一般一次插 20 到 30 条,价格拉开梯度、书名覆盖不同分类,前端列表页和分页导航才有效果。插入脚本节选如下:
-- 插入模拟数据(节选) INSERT INTO `book` (`title`, `author`, `publisher`, `price`, `stock`, `description`) VALUES ('Java编程思想', 'Bruce Eckel', '机械工业出版社', 108.00, 50, 'Java入门经典,适合做课程设计参考'), ('MySQL高性能优化', 'Baron Schwartz', '电子工业出版社', 99.00, 30, '数据库性能优化细节'), ('深入理解计算机系统', 'Randal E.Bryant', '机械工业出版社', 139.00, 20, 'CSAPP中文版'), ('数据结构与算法分析', 'Mark Allen Weiss', '人民邮电出版社', 89.50, 40, '数据结构和算法教材'), ('计算机网络:自顶向下方法', 'James F. Kurose', '机械工业出版社', 79.00, 35, '网络原理教材');插入后执行一条SELECT COUNT(*) FROM book;确认行数。答辩现场最尴尬的瞬间,是演示分页时第二页是空的——不是代码 bug,是数据不够,这种问题最冤。提交时 SQL 脚本要包含三样:建库语句、建表语句、模拟数据。我一般拆成bookstore_schema.sql和bookstore_data.sql两个文件,schema 放 DDL,data 放 INSERT,文档说明里写清楚「先执行 schema 再执行 data」,老师拿着脚本能在自己机器上完整还原数据库环境。
3. 后端落地:Servlet + JSP + JDBC 分层,不碰框架的骨架方案
JavaWeb 数据库课设的主流技术路线是 Servlet + JSP + JDBC + MySQL。不选 SSM 的原因很现实:课设周期短,Spring 和 MyBatis 的配置对新手来说是一个黑匣子,报错以后排查链路太长,而且课程大纲这一阶段要考核的是 Servlet 规范和数据库访问能力,不是框架熟练度。Servlet 写起来虽然原始,但每一行代码都能对应到 HTTP 请求的处理流程上,答辩被追问「请求怎么从浏览器走到数据库」时,可以直接照着代码指路径。
项目结构按包分层:com.bookstore.util放 JDBC 工具类,com.bookstore.dao放数据访问层,com.bookstore.service放业务层,com.bookstore.servlet放控制器,com.bookstore.entity放实体类。实体类对应第二章的五张表,用java.math.BigDecimal接收 DECIMAL 字段,用java.time.LocalDateTime接收 DATETIME 字段,时间类型在 JDBC 里取出来是 Timestamp,要做一次转换。
3.1 JDBC 连接工具类:从 DriverManager 到连接池
数据库连接不能每次请求都 new 一个 Connection。连接建立的开销大,无限制创建会拖垮 MySQL,但课设阶段上 HikariCP 之类的连接池又显得重。折中做法是一个静态工具类统一管理连接获取和释放,代码量小,导师也能看懂。下面是完整的 DBUtil:
package com.bookstore.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/bookstore?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { // MySQL 8.x 必须用 com.mysql.cj.jdbc.Driver,老版本才是 com.mysql.jdbc.Driver Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError("MySQL驱动加载失败,请检查jar包是否已导入"); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(AutoCloseable... resources) { for (AutoCloseable r : resources) { if (r != null) { try { r.close(); } catch (Exception ignored) { } } } } }URL 上的几个参数是 MySQL 8 的标配:useSSL=false去掉 SSL 握手告警,serverTimezone=Asia/Shanghai解决时区报错,allowPublicKeyRetrieval=true解决 MySQL 8caching_sha2_password认证模式下客户端首次连接报 public key retrieval 错误。三个参数少一个,项目第一次跑起来很可能在连接阶段报错,而且错得莫名其妙。驱动类名也要注意,MySQL 8 之前是com.mysql.jdbc.Driver,8 之后是com.mysql.cj.jdbc.Driver,用错直接 ClassNotFoundException。
密码明文写在工具类里安全上是硬伤,但课程设计场景下这是最常见做法。答辩被问「生产环境账号密码怎么办」,准备一套回答:生产环境会把配置外置到 properties 文件并对密码加密,DBUtil 只负责读取;课设里写常量是为了保证解压即跑的代码内聚性。把这个问题答圆了,反而是加分项。
3.2 用户登录与会话管理:PreparedStatement 和 Filter 的统一校验
登录模块是数据库「查」操作的典型应用。登录校验核心流程三步:接收参数、按用户名查库、比对密码摘要。注意不要用「先查出所有用户再在 Java 里遍历比对」这种写法,数据库索引存在的意义就是让WHERE username = ?快速定位,全表扫描进内存再过滤是性能反面教材。
// UserDAO.java 核心方法 public User findByUsername(String username) throws SQLException { String sql = "SELECT id, username, password, nickname, phone, create_time FROM user WHERE username = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { User u = new User(); u.setId(rs.getInt("id")); u.setUsername(rs.getString("username")); u.setPassword(rs.getString("password")); u.setNickname(rs.getString("nickname")); u.setPhone(rs.getString("phone")); u.setCreateTime(rs.getTimestamp("create_time").toLocalDateTime()); return u; } } } return null; }这段代码的三个关键点:PreparedStatement 占位符防 SQL 注入、try-with-resources 自动关闭连接、ResultSet 用完即走。答辩时可以展开讲「为什么不用 Statement 拼接 SQL」——用户输入admin' OR '1'='1会绕过密码校验,PreparedStatement 让参数与 SQL 模板分离,这是 SQL 注入防线的最基础形态。
登录成功后,把用户对象放进 Session。所有需要登录才能访问的页面统一在 Filter 里拦截,不在每个 Servlet 里重复写判断。Filter 的核心逻辑极短:
// LoginFilter.java public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; Object user = req.getSession().getAttribute("loginUser"); if (user == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } chain.doFilter(request, response); }Filter 在 web.xml 里配置时拦截/cart/*、/order/*这样的路径,登录页和图书列表页要放行。初学最容易犯的配置错误是把拦截路径写成/*,导致登录页自己也被拦,进入「无法登录」的死循环。另一个经验:映射顺序决定执行顺序,公开接口要么不匹配拦截规则,要么在 Filter 内部放行。
登录密码比对一个注意点:数据库存的是 MD5 摘要,登录时要把用户输入转成 MD5 再比对,不要在 SQL 里直接setString(2, rawPassword)。MD5 工具类就是 JDK 自带 MessageDigest 包一层,几十行代码,但加上这个环节,文档说明里「安全设计」一节就有内容写了。
3.3 购物车与订单:一个事务里完成库存扣减和订单生成
购物车加购是简单的 Insert 或 Update,但「下单」这个动作必须保证原子性:扣减库存、生成订单、生成订单明细、清空购物车,任何一个失败都不能让数据处于半成品状态。事务是这张表代码的核心。
// OrderService.java 核心方法 public void checkout(int userId) throws SQLException { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交,开启事务 // 1. 查询购物车 String cartSql = "SELECT c.book_id, c.quantity, b.price FROM cart c JOIN book b ON c.book_id = b.id WHERE c.user_id = ?"; // 执行查询得到 cartItems // 2. 生成订单,拿到自增ID String orderSql = "INSERT INTO orders (order_no, user_id, total_amount, status) VALUES (?, ?, ?, 1)"; // 生成订单号并执行,通过 getGeneratedKeys() 取自增 id // 3. 扣减库存:带库存校验条件的 UPDATE String deductSql = "UPDATE book SET stock = stock - ? WHERE id = ? AND stock >= ?"; // 影响行数为 0 说明库存不足,抛异常触发回滚 // 4. 批量插入订单明细 // 5. 清空购物车 DELETE FROM cart WHERE user_id = ? conn.commit(); } catch (SQLException e) { if (conn != null) conn.rollback(); throw e; } finally { if (conn != null) { conn.setAutoCommit(true); // 恢复自动提交,避免连接池污染 conn.close(); } } }扣库存的 SQL 值得单独展开:UPDATE book SET stock = stock - ? WHERE id = ? AND stock >= ?。加AND stock >= ?保证库存不为负,更新影响行数为 0 就抛异常回滚。这种写法本质是乐观锁的变体,比「先 SELECT stock 再判断再 UPDATE」安全得多,能防两个请求同时读到相同库存导致超卖。事务边界要清楚:Connection 从一个地方拿,事务在 Service 方法开头打开,finally 块里必须把setAutoCommit(true)恢复。不恢复的后果很神秘——连接回到池里后自动提交仍是关闭的,下一个请求拿到这个连接,后续所有 SQL 都堆在内存里不落库,表现是「第一句 SQL 没生效,全在排队」,这是 JDBC 事务里最容易翻车的地方之一。
4. JSP 页面与前后端联调:列表、分页、购物车的页面链路
JSP 页面不求美观,但要完整覆盖增删改查的展示入口。典型页面组合是:登录/注册页、图书列表页、图书详情页、购物车页、结算页、订单列表页,加分项是后台图书管理页。页面跳转逻辑要一眼能看懂:列表页点详情进详情页,详情页点加入购物车回列表或进购物车,购物车点结算生成订单跳订单列表。JSP 里用 JSTL + EL 表达式取 Session 和请求域数据,禁止写<% %>Java 代码片段——内联代码既难读又难维护,IDEA 对内联 Java 的补全也弱,答辩现场临时改需求时排查成本高得离谱。
编码过滤器是前后端联调的基石,JSP 页面和 Servlet 之间所有中文传递都依赖它。我之前见过太多课设项目把request.setCharacterEncoding写在每个 Servlet 里,漏一个就乱码一处,而用 Filter 统一处理是标准姿势:
// EncodingFilter.java public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding("UTF-8"); response.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8"); chain.doFilter(request, response); }注意要在chain.doFilter之前设置编码,否则请求体已被读取,再设置就来不及了。这个 Filter 拦截所有路径,放在 web.xml 过滤器列表的第一位。
4.1 分页查询的 SQL 写法与页码联动
图书列表页的分页是数据库课程设计必考项。SQL 写SELECT * FROM book ORDER BY id LIMIT offset, size,页面显示「第 x / y 页,共 n 条」,前后翻页。分页 SQL 必须带 ORDER BY,不带排序的 LIMIT 返回顺序不稳定,翻页时会出现「上一页看过的书在下一页又出现」的诡异现象。
-- 第 2 页,每页 6 条:offset = (2-1) * 6 = 6 SELECT id, title, author, publisher, price, stock FROM book ORDER BY id LIMIT 6, 6;配套的查询总数 SQL 是SELECT COUNT(*) FROM book,两个查询结果组合成一个 PageBean 对象放进请求域。页码参数从 JSP 的request.getParameter("page")获取,必须做默认值处理:参数为空或非法时默认第 1 页,用户手动输入?page=99999时要能兜底为空列表而不是报错。页码范围在后端做边界控制:当前页小于 1 就显示第 1 页,大于总页数就显示最后一页。放在后端比前端 JS 更可靠,因为用户可以直接输入 URL 绕过前端。
分页导航栏的 JSP 写法用一个循环生成页码链接,当前页高亮,相邻页可点。要传多条件查询参数时,比如带关键词搜索的列表页,直接在 JSP 端用${param.keyword}把查询词拼回链接,不要用 JS 去读表单值再拼接,减少一个在答辩现场出 bug 的环节。
4.2 购物车页面的数量修改与总价计算
购物车页面展示用户加购的图书、数量、单价和合计金额。数量修改按钮对应一个CartUpdateServlet,参数为 bookId 和新的 quantity。修改后由浏览器重新请求购物车列表页,所有价格计算都在服务端完成,不要在 JSP 里用 JS 计算后直接提交单价——总价必须以数据库里的价格为准,前端算出来的只做展示。
<!-- cart.jsp 购物车列表核心片段 --> <c:forEach var="item" items="${cartList}"> <tr> <td>${item.bookTitle}</td> <td>¥${item.price}</td> <td> <a href="cart/update?bookId=${item.bookId}&quantity=${item.quantity - 1}">-</a> <span>${item.quantity}</span> <a href="cart/update?bookId=${item.bookId}&quantity=${item.quantity + 1}">+</a> </td> <td>¥${item.price * item.quantity}</td> </tr> </c:forEach>这段 JSP 里有两个可以拿出来讲的设计决策。一是price * quantity的精度:JSTL 里的乘法走 EL 表达式引擎,精度表现与 Java 里 BigDecimal 乘法并不完全一致,稳妥做法是把总价在 Service 层用 BigDecimal 算好,放进一个名为CartVO的视图对象里再渲染,而不是依赖页面表达式计算。二是加减按钮用链接发起 GET 请求,这在课设中够用,但生产环境应该用 POST — 因为 GET 会被爬虫或浏览器预读触发,导致购物车数量被意外修改。答辩时主动讲「我知道这里用 POST 更规范,但课设为了演示直观先用 GET」远比被老师追问时卡壳好得多。
下单成功后,页面跳转到订单列表,订单列表按create_time DESC排序,倒序展示最新订单在最前面。订单详情和取消功能在课设里属于锦上添花,但一旦做了取消订单,就必须保证只做状态更新而不是物理删除——这个衔接点在第五章会展开讲。
5. 避坑/常见问题排查:环境配置、中文乱码、SQL 注入与订单数据不一致
这个章节收集做 JavaWeb 书城课设时最高频的踩坑记录,按「现象 → 原因 → 解决」顺序写。每一条都是帮同学调试课设时反复出现的真实问题,不是从文档里抄来的理论。
5.1 现象一:IDEA 运行 JavaWeb 项目配置导致 Tomcat 404 或端口占用
现象:Tomcat 能启动,但打开http://localhost:8080/bookstore/login.jsp是 404;或启动直接报Port 8080 required by Tomcat v9.0 Server is already in use。
原因分两种。404 的第一嫌疑是部署名不对,IDEA 的 Artifact 配置里 Application context 设置成了/或与项目名不一致,URL 少敲或多敲了上下文路径。端口占用的原因更简单——本机已经有一个 Tomcat 在跑,或之前 IDEA 里启动的 Tomcat 进程没有正常终止,残留进程锁住了 8080。
解决:端口占用在 Windows 上先跑netstat -ano | findstr :8080找到 PID,然后taskkill /PID <pid> /F结束后重新启动。404 则打开 Run Configuration 的 Deployment 页,检查 Application context,确保浏览器访问路径和部署上下文完全匹配。还有一个经常被忽略的点:JDK 版本和 Tomcat 版本不兼容也会在启动时报莫名其妙错误,Tomcat 9 配 JDK 8/11 是稳妥组合,Tomcat 10 的包名从javax.servlet改成jakarta.servlet,旧代码直接搬过去会报 ClassNotFoundException。
5.2 现象二:数据库中文变问号或乱码
现象:从 JSP 页面提交的中文书名或用户昵称存入 MySQL 后变成???,或读出到页面显示乱码。
原因链有三层:JDBC 连接 URL 缺characterEncoding=utf8mb4、数据库或表默认字符集不是 utf8mb4、JSP 页面编码不是 UTF-8。任何一层断层,中文就会在写入或读出时失真。最常见的是建库时没指定字符集,MySQL 默认 Latin1 无法存储中文。
解决:三层同时检查。URL 按第二章代码补上useUnicode=true&characterEncoding=utf8mb4,建库语句里写DEFAULT CHARACTER SET utf8mb4,IDEA 和 Tomcat 的 VM 参数追加-Dfile.encoding=UTF-8。如果数据已经乱码,不要试图在乱码数据上补救,直接重建库跑一遍 schema 脚本最快。血泪经验:在已经乱码的数据库上调字符集,改完原来乱码的数据依然乱码,因为写入时已经丢失了信息。
5.3 现象三:登录 SQL 语法错误,或拼接用户名绕过密码登录
现象:登录时输入用户名admin' OR '1'='1和任意密码直接登录成功;或者输入含单引号的用户名登录报You have an error in your SQL syntax。
原因:DAO 层用了字符串拼接 SQL,形如SELECT * FROM user WHERE username = '" + username + "' AND password = '" + password + "'。输入的admin' OR '1'='1把原 SQL 拼成了恒真条件,直接绕过密码校验。这是最典型的 SQL 注入漏洞,也是数据库课程设计必须演示出来的知识点之一。
解决:把拼接改成占位符WHERE username = ? AND password = ?,所有参数一律用 PreparedStatement 的 setString/setInt 绑定。密码比对前先转 MD5 摘要,保证数据库里不是明文。课程设计里这一步做好,答辩被问「安全方面做了什么」就有拿得出手的实锤回答,还能顺手补一句「分页查询里的 offset 参数也走了类型转换,没有直接拼进 SQL」。
5.4 现象四:删除订单报外键约束失败
现象:执行DELETE FROM orders WHERE id = 10时报Cannot delete or update a parent row: a foreign key constraint fails,或删除图书时提示被订单明细引用。
原因:order_item 表对 orders 和 book 都有外键约束,删除父表记录时子表仍有引用记录。按业务规则,历史订单不能物理删除,只能逻辑取消(状态置为 5),所以这个问题的本质是业务允许删除但表结构不允许。
解决:把「删除订单」功能改成「取消订单」,SQL 写UPDATE orders SET status = 5 WHERE id = ?而不是 DELETE。若实在需要物理删除,程序里必须先删订单明细再删订单,两条 DAO 操作放在同一个事务里。外键的作用是保护数据完整性,这个坑不靠代码绕过,靠业务规则绕开。被问到「为什么订单不能删」时,回答「订单涉及库存和财务,业务上只能作废不能删除」就是标准答案。
5.5 现象五:下单成功但库存没减,或库存变成负数
现象:演示下单流程时,订单生成了,但图书列表里的库存数字不变;或者快速两次下单同一本书,库存变成负数。
原因:下单逻辑没有用事务,插入订单成功但扣库存失败后未回滚;扣库存的 SQL 没有带stock >= ?条件,并发时两个事务都读到旧库存,都把剩余库存减成负数。库存为负是数据库层面最明显的脏数据标志。
解决:把下单动作包在单连接事务里,参考第三章 3.3 的代码,setAutoCommit(false)后依次执行扣库存、插入订单、插入订单明细、清空购物车,全部成功才commit(),任一步失败则rollback()。扣库存 SQL 加AND stock >= ?条件,影响行数为 0 就抛异常回滚。这个点能解释清楚并发场景下的数据一致性,期末加分效果非常明显。
6. 演示与验证:把课设从「能跑」优化到「能答辩」的三个动作
课设代码写完能从登录跑到下单发货,和答辩现场老师愿意打高分,中间还差三个动作:预置演示数据、规划演示路径、测试边界输入。
第一个动作是预置演示账号。在 data 脚本末尾插入一个明确的演示账号,用户名demo,密码123456,提前算好 MD5 摘要写进 INSERT。答辩时不要当场上机注册新账号——注册流程容易在唯一索引、密码强度校验、网络延迟这几个环节出问题,现场注册浪费时间。用预置账号登录,把时间留给图书浏览和下单链路。演示账号用过要在文档说明里显著标注,老师拿到项目后直接按文档操作进入系统,体验会好很多。
第二个动作是规划演示路径。一条完整的路径是:登录 → 浏览图书列表 → 翻页一次 → 进入详情页 → 加入购物车 → 购物车改数量 → 结算下单 → 查看订单列表 → 取消订单。每演示一步,心里都要过一遍数据库里对应执行了哪条 SQL。老师问「这个操作改了哪些表」,你能立刻回答,这是拿分的核心节点。演示时先讲页面再讲 SQL,不要把时间耗在代码逐行解释上,代码留给老师抽查。
第三个动作是测试边界输入。图书搜索框输入不存在的书名、购物车数量改为 0、分页点击最后一页、重复提交下单按钮。这些边界场景在答辩前自己跑一遍,老师在现场随手操作时你就不会翻车。下单按钮的重复提交问题尤其要注意——最快的兜底方案是点击结算后前端禁用按钮并对order_no做唯一索引约束,重复提交报错能明确提示而不是生成两笔相同订单。
我自己的习惯是答辩前一天把整个流程录一遍视频,按演示顺序走完,把每个 SQL 操作对应的页面截图放进文档说明。这个习惯帮我躲过很多现场意外,比如临时断网连不上数据库,或者答辩教室的浏览器缓存了旧页面。一份课设的文档说明不只是写给别人看的,它就是你的演示脚本和后悔药。希望帮到你。
本文还有配套的精品资源,点击获取