简介面向课程设计与毕业设计场景的JavaEE网上书店项目提供完整源代码与MySQL建库脚本适合需要完成Web课程作业、理解企业级应用分层与全栈流程的学生参考。压缩包共88个文件约519KB以40个JSP页面、11个Java源文件及11个class文件为主体并配有CSS样式、图片素材、XML配置和SQL脚本分别承担页面展示、业务逻辑、资源支撑与数据库初始化等工作。项目围绕用户注册登录、图书展示与搜索、购物车、订单处理及后台管理等模块展开读者可结合Servlet、JSP、JPA等技术点梳理请求处理、数据持久化与权限控制思路并据此搭建可运行环境。项目还涉及依赖管理、单元测试与第三方支付集成等拓展方向对理解JavaEE架构、数据库表结构设计与后台权限区分具有参考价值。已有3142人学习可作为课程设计原型或毕业设计基础工程便于二次开发与功能扩展。1. 从“能登录”到“能下单”这段最难啃的链路做网上书店的课程设计大部分人会卡在第三步需求写了三页表建了十张图书列表也跑出来了结果购物车一刷新就清空下单那段事务一跑就抛异常。这套基于 JavaEE 的网上书店项目完整代码 sql 文件要解决的就是从“能登录”到“能下单”这段最短也最难的链路——图书分类浏览、关键词检索、购物车增减、订单生成、后台图书与订单管理全部落在同一套 MySQL 表结构里打通。它面向的是要交一份完整毕业设计或课程设计的同学也适合想拿一套标准三层架构当脚手架、往上叠功能的开发者。下面按需求、选型、库表、模块、避坑、验收的顺序把这套东西拆开讲。2. 需求拆解与技术选型三层架构下六个模块的职责边界2.1 业务需求把书店压成一条最短闭环网上书店的业务看着杂真正要跑通的只有一条闭环游客能按分类或关键词找到书登录用户能把书加进购物车结算时填一个收货地址系统生成订单并扣减库存管理员在后台能看到这笔订单并把状态往前推。围绕这条闭环往外扩才轮到收藏、评论、销量排序、订单取消这些加分项。前台模块的划分通常是这样用户模块负责注册、登录、退出和地址簿图书模块负责分类导航、列表分页、详情展示购物车模块负责加入、改数量、删除、勾选结算订单模块负责提交、查询、取消。后台模块则是图书上下架、分类维护、订单发货、用户禁用。两边共用同一套实体和 DAO只是控制器和页面不同。需要注意的是课设和毕设的评分点集中在“业务逻辑是否闭环”而不是功能数量。宁可把下单扣库存这一条路走扎实也不要把收藏、积分、优惠券全堆上去最后每一块都只到列表页。下面这张表是我一般会写进开题报告里的模块职责划分直接拿去改改就能用模块主要职责关键接口对应页面用户注册登录、密码校验、地址增删改/user/login、/user/registerlogin.jsp、register.jsp图书分类导航、多条件检索、分页、详情/book/list、/book/detailbook_list.jsp、book_detail.jsp购物车加入、改数量、删除、合计金额/cart/add、/cart/updatecart.jsp订单结算、生成订单、查询、取消/order/submit、/order/listorder_confirm.jsp、order_list.jsp后台图书增删改查、上下架、库存调整/admin/book/saveadmin/book_edit.jsp后台订单订单列表、发货、关闭/admin/order/shipadmin/order_list.jsp2.2 技术选型Servlet JSP JDBC 这套“老组合”为什么反而最稳现在一提 Java Web很多人第一反应是 Spring Boot 一把梭。放到课程设计这个场景里我反而不推荐。原因很实际答辩老师要看你写的代码里有没有分层、有没有事务、有没有过滤器Spring Boot 的自动配置把很多东西藏进了注解和 starter 里你讲不出“连接是谁关的”“事务是谁提交的”反而容易被追问。这套项目用的是 Servlet JSP JDBC JSTL 的经典组合通常的做法是JDK 8 及以上、Tomcat 8.5 或 9、MySQL 5.7 或 8.0、Druid 或 C3P0 连接池、JSTL 1.2 做页面循环和格式化。依赖少war 包丢进 webapps 就能跑不需要额外装什么运行时。血泪经验是课设环境经常是机房老机器依赖越少部署当天翻车的概率越低。分层的职责要划清楚不然写到后面就是 Servlet 里塞 SQLentity实体层和数据库表一一对应的 POJO只放字段、getter/setter不放业务逻辑。dao数据访问层只做 CRUD一个方法对应一条或一组 SQL不判断业务规则。service业务层事务边界在这里下单、扣库存、清购物车必须写在同一个方法里。controller控制层Servlet 只负责收参数、调 service、跳页面不写 SQL 也不写业务判断。util工具层连接池、MD5、分页封装、日期处理。判断自己有没有分错层有个土办法把 dao 层整个删掉service 层应该编译不过把 controller 层删掉service 层应该还能单独跑单元测试。做不到这两点说明业务逻辑漏到别处去了。2.3 包结构与请求流转一次“加入购物车”到底经过了谁包结构建议按层划分而不是按功能划分因为课设代码量不大按层更好讲。目录大致长这样每一层的作用我写在注释里src/main/java/com/bookstore ├── entity # 实体类Book、User、Order、OrderItem、CartItem ├── dao # 接口 impl只写 SQL不写业务判断 │ ├── BookDao.java │ ├── OrderDao.java │ └── impl/BookDaoImpl.java ├── service # 业务与事务边界下单在这里提交/回滚 ├── controller # Servlet收参 → 调 service → 转发或重定向 ├── filter # 编码过滤器、登录拦截过滤器 └── util # DBUtil 连接池、MD5Util、PageBean 分页封装 src/main/webapp ├── WEB-INF/views # JSP 放这里防止被直接访问 ├── static # css、js、图书封面图 └── WEB-INF/web.xml一次“加入购物车”的完整链路是这样的浏览器 POST 到/cart/add编码过滤器先把request.setCharacterEncoding(UTF-8)设好登录拦截过滤器检查 session 里有没有loginUser没有就重定向到登录页CartServlet 拿到bookId和quantity做非空和正整数校验调 CartServiceService 先查库存够不够再决定是 insert 还是 update 已有记录最后 Servlet 重定向回/cart/list而不是直接 forward避免刷新页面重复提交。提示列表页跳转用重定向、表单回显用转发这条规矩能省掉一大半“刷新重复提交”的问题。3. 数据库设计七张表撑起图书、购物车与订单主链路3.1 核心表结构字段、索引与注释一次到位库表是这个项目的地基sql 文件里通常包含建库、建表、初始化数据三段导入顺序不能乱。下面给出最关键的几张表字段名和注释都保留了改改就能用。CREATE DATABASE IF NOT EXISTS bookstore DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci; USE bookstore; -- 用户表role 区分前后台不单独建管理员表 CREATE TABLE t_user ( id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, username VARCHAR(32) NOT NULL COMMENT 登录名, password CHAR(32) NOT NULL COMMENT MD5 摘要不存明文, nickname VARCHAR(32) DEFAULT NULL COMMENT 昵称, phone VARCHAR(20) DEFAULT NULL, role TINYINT NOT NULL DEFAULT 0 COMMENT 0-普通用户 1-管理员, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-正常 0-禁用, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; -- 图书表discount_price 为空表示不打折检索时用 IFNULL 兜底 CREATE TABLE t_book ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, title VARCHAR(128) NOT NULL COMMENT 书名, author VARCHAR(64) NOT NULL COMMENT 作者, publisher VARCHAR(64) DEFAULT NULL, isbn VARCHAR(20) DEFAULT NULL, category_id INT UNSIGNED NOT NULL COMMENT 所属分类, price DECIMAL(10,2) NOT NULL COMMENT 定价, discount_price DECIMAL(10,2) DEFAULT NULL COMMENT 折后价NULL 表示无折扣, stock INT NOT NULL DEFAULT 0 COMMENT 库存, sales INT NOT NULL DEFAULT 0 COMMENT 销量用于排序, cover_img VARCHAR(255) DEFAULT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-上架 0-下架, PRIMARY KEY (id), KEY idx_category (category_id), KEY idx_title (title) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT图书表; -- 订单表金额与状态放主表明细放子表 CREATE TABLE t_order ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务单号非自增主键, user_id INT UNSIGNED NOT NULL, total_amount DECIMAL(10,2) NOT NULL COMMENT 订单总额, receiver VARCHAR(32) NOT NULL COMMENT 收货人, phone VARCHAR(20) NOT NULL, address VARCHAR(255) NOT NULL, order_status TINYINT NOT NULL DEFAULT 0 COMMENT 0-待付款 1-已付款 2-已发货 3-已完成 4-已取消, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单主表; -- 订单明细书名和单价冗余存一份做历史快照 CREATE TABLE t_order_item ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, order_id INT UNSIGNED NOT NULL, book_id INT UNSIGNED NOT NULL, book_title VARCHAR(128) NOT NULL COMMENT 下单时的书名快照, price DECIMAL(10,2) NOT NULL COMMENT 下单时的单价快照, quantity INT NOT NULL, PRIMARY KEY (id), KEY idx_order (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单明细表;购物车表t_cart、分类表t_category、地址表t_address的结构同理字段不多重点是购物车表要有uk_user_book (user_id, book_id)这个唯一索引靠它把“重复加同一本书”变成一次 update 而不是插两条。索引这块课设里最常见的错误是只建主键。t_book.title上有 LIKE 查询、t_order.user_id上有“我的订单”查询这两个字段不加索引数据量一过两千条页面就开始转圈。3.2 订单与订单明细为什么必须拆两张表经常有人问订单里就买两三本书为什么不能直接在订单表里塞一个book_ids字段用逗号隔开能跑但答辩时会被问到答不上来。拆表的核心理由是“一个订单对应多个商品”是一对多关系塞逗号串等于把关系模型压平成字符串之后就做不了三件事按图书统计销量、按明细退款、按商品查历史订单。更关键的是快照问题——图书的价格和书名是会变的如果明细表只存book_id用户三个月后翻订单看到的会是当时的订单金额配上现在的书名和现价对不上账。所以明细表里冗余book_title和price两个字段是刻意为之不是设计缺陷。order_no也值得单独说。主键id用自增但对外展示和查询用order_no通常是“年月日时分秒 四位随机数”拼出来的字符串。这样做的好处是不暴露订单总量、方便按单号检索、将来分库也不用改主键策略。3.3 sql 文件的导入顺序与初始数据sql 文件导入失败的九成原因是顺序和字符集。正确的顺序是先建库并USE再建表如果有外键依赖被依赖的表先建最后插数据。字符集统一用utf8mb4别用utf8后者在 MySQL 里其实是三字节的 utf8mb3存 emoji 或者某些生僻字会报错。初始数据一般准备三类一条管理员账号密码存 MD5 摘要登录时前端传明文、后端摘要比对、五到八个图书分类、二十到三十本图书。图书数据不用手写用INSERT INTO ... VALUES (...),(...),(...)批量插一条语句搞定效率比一条条 insert 高一个数量级。注意导入前先确认 MySQL 的sql_mode里有没有STRICT_TRANS_TABLES。开了严格模式缺字段的非空列会直接报错而不是给默认值很多人在自己机器上能导入、换台机器就失败就是差在这里。4. 核心模块实现图书分页检索、购物车与下单事务4.1 图书分页与多条件检索一条 SQL 兼顾关键词和分类分页最容易写崩的地方是“动态条件拼装 总条数统计”这两段。前者容易漏掉空格导致 SQL 语法错误后者容易和列表查询的条件不一致导致总页数算错。我的做法是把条件抽成一个私有方法列表查询和计数查询共用。public class BookDaoImpl implements BookDao { /** 拼接 where 条件列表查询和 count 查询共用同一段避免条件不一致 */ private String buildWhere(String keyword, Integer categoryId, ListObject params) { StringBuilder where new StringBuilder( WHERE status 1 ); if (StringUtils.isNotBlank(keyword)) { where.append( AND (title LIKE ? OR author LIKE ?) ); params.add(% keyword.trim() %); params.add(% keyword.trim() %); } if (categoryId ! null categoryId 0) { where.append( AND category_id ? ); params.add(categoryId); } return where.toString(); } Override public ListBook findByPage(String keyword, Integer categoryId, int offset, int pageSize) { ListObject params new ArrayList(); // IFNULL 兜底没有折后价时按定价展示前端不用再判空 String sql SELECT id, title, author, price, IFNULL(discount_price, price) AS salePrice, stock, cover_img FROM t_book buildWhere(keyword, categoryId, params) ORDER BY sales DESC, id DESC LIMIT ?, ?; params.add(offset); params.add(pageSize); return DBUtil.queryList(sql, params, Book.class); } Override public int countByCondition(String keyword, Integer categoryId) { ListObject params new ArrayList(); String sql SELECT COUNT(*) FROM t_book buildWhere(keyword, categoryId, params); return DBUtil.queryCount(sql, params); } }几个参数得说清楚。offset是(pageNo - 1) * pageSizePageBean 里算好再传进来DAO 不关心当前是第几页。pageSize一般固定 10 或 12跟着列表页的栅格走别做成用户可调课设里没必要。ORDER BY sales DESC, id DESC的后半段是必须的否则销量相同的书在翻页时顺序会抖动出现“第二页又看到第一页的书”这种玄学现象。DBUtil.queryList内部用的是PreparedStatement参数按顺序setObject这套写法防注入也避免了拼接字符串时引号处理出错。4.2 购物车Session 存还是数据库存别两头都做购物车有两种存法各有取舍。Session 存法把MapInteger, CartItem挂在会话上好处是不用建表、响应快坏处是换浏览器就没了、服务器重启就丢了。数据库存法落地到t_cart表好处是能跨设备、能统计弃购坏处是每次加减都要读写库。我的建议是登录用户走数据库未登录游客走 Session登录成功后做一次合并。这个逻辑写在登录的 service 里别散落在各处。合并的规则很简单——同book_id的数量相加但不超过库存。Override public void mergeSessionCart(int userId, MapInteger, CartItem sessionCart) { if (sessionCart null || sessionCart.isEmpty()) return; for (CartItem item : sessionCart.values()) { // 先查库里有没有这条记录有就累加没有就新增 CartItem exist cartDao.findByUserAndBook(userId, item.getBookId()); if (exist null) { item.setUserId(userId); cartDao.insert(item); } else { int total exist.getQuantity() item.getQuantity(); // 上限锁死在库存防止购物车里出现买不到的数量 exist.setQuantity(Math.min(total, item.getStock())); cartDao.updateQuantity(exist.getId(), exist.getQuantity()); } } sessionCart.clear(); // 合并完清空避免下次登录重复累加 }最后那行sessionCart.clear()是个容易被忘掉的细节忘了就会出现“退出再登录购物车数量翻倍”的怪事。4.3 下单一个事务里必须做完的四件事下单是整个项目里唯一必须用事务的地方。一个订单提交成功意味着四件事同时成立写入订单主表、写入订单明细、扣减库存、清空已下单的购物车记录。任何一步失败前面做的都要撤回否则就会出现“扣了库存没生成订单”或者“生成了订单没扣库存”的脏数据。public String submitOrder(int userId, ListCartItem items, Address addr) throws SQLException { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务关掉自动提交 // 1. 校验库存任意一本不足就整体失败宁可全失败不要部分成功 for (CartItem item : items) { int stock bookDao.getStockForUpdate(conn, item.getBookId()); // SELECT ... FOR UPDATE if (stock item.getQuantity()) { throw new BizException(《 item.getTitle() 》库存不足); } } // 2. 写订单主表order_no 用时间戳 随机数生成 String orderNo OrderNoUtil.generate(); int orderId orderDao.insertOrder(conn, orderNo, userId, calcTotal(items), addr); // 3. 逐条写明细并扣库存扣减用 stock stock - ? 而不是先查后改 for (CartItem item : items) { orderItemDao.insert(conn, orderId, item); bookDao.decreaseStock(conn, item.getBookId(), item.getQuantity()); } // 4. 清掉已下单的购物车记录 cartDao.deleteByIds(conn, items.stream().map(CartItem::getId).collect(Collectors.toList())); conn.commit(); // 四步全过才提交 return orderNo; } catch (Exception e) { if (conn ! null) conn.rollback(); // 任何一步异常整体回滚 throw new BizException(下单失败 e.getMessage()); } finally { if (conn ! null) { conn.setAutoCommit(true); // 归还连接池前恢复现场 conn.close(); } } }几个设计点值得单独讲。getStockForUpdate用的是SELECT ... FOR UPDATE在事务里给这一行加排他锁防止两个用户同时下单把库存扣成负数这是并发场景下最省事的做法。扣减库存的 SQL 写成UPDATE t_book SET stock stock - ? WHERE id ? AND stock ?把判断和扣减合成一条原子语句比“先查再改”稳得多。finally里恢复autoCommit那一行千万别省连接池是复用连接的不恢复现场下一个拿到这条连接的请求会莫名其妙地不提交事务。4.4 编码过滤器与登录拦截两个 Filter 管住全局中文乱码和越权访问是课设里最高频的两个问题都能用一个 Filter 解决掉不用在每个 Servlet 里重复写。WebFilter(filterName encodingFilter, urlPatterns /*) public class EncodingFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; // 请求体编码解决 POST 中文乱码 request.setCharacterEncoding(UTF-8); // 响应编码 内容类型解决输出中文乱码 response.setCharacterEncoding(UTF-8); response.setContentType(text/html;charsetUTF-8); chain.doFilter(req, resp); } }登录拦截 Filter 的放行名单要写全一般包括登录页、注册页、静态资源目录、图书列表和详情页——游客也应该能浏览书。判断依据是session.getAttribute(loginUser)是否为空为空就response.sendRedirect(request.getContextPath() /login.jsp)。这里用重定向不用转发是因为转发后浏览器地址栏还停在原地址用户刷新会重复提交。提示request.getContextPath()不能省。项目部署名不叫 ROOT 时写死/login.jsp会 404这是本地能跑、换个环境就挂的经典原因。5. 常见问题排查五个最容易翻车的点5.1 中文乱码POST 和 GET 走的是两条路现象表单提交的中文书名存进数据库变成问号图书列表页显示成“???”但英文和数字一切正常。原因POST 请求体的编码由request.setCharacterEncoding(UTF-8)决定必须在第一次读取参数之前调用GET 请求的参数走的是 URL受 Tomcat 的server.xml里Connector的URIEncoding控制跟上面那行代码没关系。两种请求混着改就很容易只修好一半。解决Filter 里设请求和响应编码解决 POSTTomcat 8 及以上默认URIEncoding已经是 UTF-8如果还乱检查数据库连接串有没有带useUnicodetruecharacterEncodingutf8以及建库时是不是用了utf8mb4。三层都要对连接串、库表字符集、请求响应编码。5.2 库存被扣成负数现象两个人同时买最后一本书订单都生成成功库存变成 -1。原因“先 SELECT 查库存判断够不够再 UPDATE 扣减”这个写法在并发下不安全两个事务可能都读到 1都判断通过然后各自扣 1。解决把判断塞进 UPDATE 语句里用UPDATE t_book SET stock stock - ? WHERE id ? AND stock ?看受影响行数是不是 1是 0 就抛业务异常回滚。或者在事务开头用SELECT ... FOR UPDATE锁住这一行。前者更轻量后者更好讲原理答辩时两种都说一遍最稳。5.3 分页的总条数和列表对不上现象列表页显示 10 条数据但分页条只有 1 页或者翻到第二页出现第一页的书。原因列表查询用了完整条件计数查询漏了某个条件或者ORDER BY没有唯一字段兜底销量相同的书在两次查询里顺序不一致。解决像我前面写的那样把 where 条件抽成一个方法共用。ORDER BY后面永远补一个主键id DESC保证排序结果稳定。另外LIMIT的偏移量用(pageNo - 1) * pageSize算pageNo从 1 开始别从 0 开始。5.4 连接池耗尽页面卡死不动现象点几下页面之后整个应用没反应日志里出现waiting for a connection或者超时异常。原因某个 DAO 方法里Connection、PreparedStatement、ResultSet有一处没关。连接池最大连接数就那么几个泄漏几次就耗光了。解决用 try-with-resources 包住所有 JDBC 资源或者统一在DBUtil的close方法里按ResultSet → Statement → Connection的逆序关闭每个都判空并 try-catch 包住。排查阶段可以临时把连接池最大连接数调小到 2问题会立刻暴露出来改完再调回去。5.5 部署到服务器上全是 404现象本地 IDEA 里跑得好好的打成 war 包丢进 Tomcat 就 404静态资源也加载不出来。原因路径写死了。/css/style.css这种以斜杠开头的绝对路径在部署名不是 ROOT 的时候会指向服务器根而不是应用根。JSP 放在WEB-INF外面还会导致能绕过控制器直接访问。解决所有页面里的链接和静态资源引用统一加${pageContext.request.contextPath}前缀JSP 全部放进WEB-INF/views通过 Servlet 转发访问。这样即使项目部署名改成bookstore路径也不会错。6. 验收与答辩一套能照着念的演示顺序验收前先跑一遍这份清单任何一项不过答辩现场就会变成现场调试检查项通过标准常见失败点sql 文件导入空库执行无报错7 张表齐全字符集不是 utf8mb4、外键顺序错注册登录新用户能注册密码错误有提示密码没做 MD5、重复用户名没拦截图书检索关键词 分类组合查询有结果where 条件拼接漏空格分页翻到最后一页不报错总数正确count 查询条件不一致购物车加、改、删、合并正常登录后 Session 购物车没合并下单库存正确扣减购物车清空事务没回滚、autoCommit 没恢复后台上下架后前台立即生效缓存或 status 字段没判断演示顺序建议按“用户视角走一遍完整链路”而不是按模块逐个点先注册一个新账号检索一本书加购物车改数量填地址提交订单然后切到管理员账号查这笔订单并发货最后切回用户看订单状态变成已发货。这条线走完业务闭环就讲清楚了剩下的功能介绍都是补充。答辩时最容易被追问的三个点提前准备好答案为什么用 Servlet 不用 Spring Boot答分层可见、事务和连接管理能讲清楚下单的事务边界在哪答从setAutoCommit(false)到commit()中间四步任何一步失败都rollback()并发下单怎么防超卖答stock stock - ? WHERE stock ?或者FOR UPDATE。我自己的习惯是答辩前一晚一定要在干净的机器上从零走一遍装 MySQL、导 sql、部署 war、注册新号、下单。有一回就是漏了这步现场发现连接串里还写着我本地的密码白丢十分钟。从那以后我每次交付前都强制走一遍“空库导入 全新账号下单”不跳步。希望帮到你。本文还有配套的精品资源点击获取