ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

高校教材订购管理系统开发实战:从业务闭环到订单状态机与库存扣减

高校教材订购管理系统开发实战:从业务闭环到订单状态机与库存扣减

简介:这是一份高校教材订购管理系统的毕业设计完整源码包,目标受众是计算机相关专业学生、毕业设计开发者以及需要为高校搭建教材信息化管理系统的技术人员。系统采用B/S架构,后端以Java编写,前端使用Vue框架,覆盖用户登录、教材信息管理、订单处理、库存管理、报表统计等核心模块,能够有效解决教材采购审批、库存同步、订单追踪等传统人工管理中的常见痛点。压缩包内共收录398个文件,总体积为5.62MB,其中以118个Vue组件、87个Java源文件、35个HTML页面和31个JS脚本为主,同时包含配置文档、数据库脚本以及少量演示图片与音视频素材,目录结构按前后端模块组织,便于读者定位代码。目前已有124人学习和下载。该资源的价值在于展示了一个相对完整的教材订购系统从数据库设计、后端接口到前端交互的实现过程,既可直接导入开发工具运行验收,也可参考其业务逻辑和界面布局完成个性化改造,非常适合毕业设计、课程项目或作为企业级教材管理系统的开发原型。

1. 高校教材订购管理系统:毕设选题里的“业务闭环样板”,别把它当普通 CRUD

“高校教材订购管理系统”这类毕业设计题目,在很多高校软件工程专业的选题表里挂了多年。名字听着普通,但它不是“教材增删改查”那种一眼看到底的 CRUD 项目——它要管学生下单、教师审核、教材入库、库存扣减、班级分发,还要面对一个学期一轮的集中订购高峰。你可以把它拆成一条完整的业务闭环:学生发起订购,辅导员或管理员审核,教材科汇总采购,仓库入库,再按班级发放到人。

这套系统真正抓人的地方在于:它能同时训练后端的事务能力、权限设计能力和多角色协作建模。对要做毕设的同学,它比“图书管理系统”“新闻发布系统”更能讲出设计深度;对想巩固服务端开发的熟手,它也是一个能用来练订单状态机和并发扣库存的中型练手题。它解决的是“一个开发者如何在几十个小时内,体验从需求分析、表设计、核心接口开发到部署答辩的完整链路”。适合两类人:一是需要一份拿得出手的毕设项目的人;二是已经会写增删改查,想补一下“复杂业务逻辑怎么组织”的从业者。下文我会按“拿到压缩包之后怎么跑、怎么改、怎么排查、怎么答辩”的顺序,把这套系统拆开讲。

2. 先把业务拆成订单流:角色权限、教材入库与状态机的设计取舍

拿到源码后,第一件事不是急着点运行按钮,而是先梳理业务。我不能替你做技术选型,但能告诉你哪些地方是这套系统的“题眼”。一个教材订购管理系统,如果需求只是“学生选教材,管理员看订单”,那没有区分度。答辩时老师一定会问:不同角色怎么区分权限?订单状态怎么变化?教材库存被同时抢怎么办?所以在改代码前,先想清楚这几个模型,你再回头看源码,会立刻明白别人为什么这么建表。

这一章我不贴完整源码,但会给你一套可以抄的表结构和状态定义。这套设计不依赖具体框架,Spring Boot、SSM、甚至裸 Servlet 都能用。等你对照中式项目时,先看它的表少没少东西,再看它的状态有没有做成死路。

2.1 系统角色拆解:学生、教师、教材管理员各管什么

多数毕设会做三种角色,外加一个超级管理员。学生负责维护自己的班级、学号、选教材、提交订单、查看订单状态;教师或辅导员负责审核本班学生订单、导出订购名单,有时候可以发起班级统一订购;教材管理员负责维护教材目录、学期计划、出版社、书号、价格、库存,审核汇总订单,入库和发放,查看统计报表。超级管理员更多是管理用户和系统参数,工作量不大。

角色权限模型用最简单的 RBAC 就够,不需要引入重框架。我在类似项目里的常见做法是:user表里带一个role字段,1 学生、2 教师、3 管理员;后端做一个拦截器,校验登录用户角色后决定接口是否放行。好处是代码直观,答辩好讲;缺点是不容易扩展,但作为毕设反而能控制工作量。

权限控制的要点是“不能只在前端做”。很多学生做的系统,前端把“删除教材”按钮藏起来就算权限控制,这是会被问倒的。后端每个敏感接口都必须校验当前登录角色。我在实际评审中见过不少项目,前端隐藏按钮后,用接口工具直接调用删除接口还能删掉数据,这就是典型的只防君子不防小人。下面这个表是我建议在文档里画出来的,比纯文字更清楚:

功能点学生教师/辅导员教材管理员
浏览教材目录是是是
提交订购单是否否
审核班级订单否是是(汇总)
教材入库否否是
订单统计导出否是(班级)是(全校)

2.2 订单状态流转:从“待审核”到“已发放”的闭环

教材订购订单不是一步到位的。如果状态只有“未支付/已支付”,那这套系统就不用做了。教材订购的特殊性在于:学生提交订单后,需要教师确认这个学生确实选了这门课,管理员再确认教材科有没有采购计划,最后仓库入库、班级发放。中间任何一步都可能被驳回。

最常见状态机是:0 待审核,1 已通过,2 已汇总,3 待领取,4 已发放,5 已取消。学生提交后是 0,教师审核通过变成 1,管理员确认采购计划变成 2,教材到货入库变成 3,按班级发放变成 4。审核不通过或学生取消,统一记录为 5。这个流转看起来简单,但很多人的代码里状态是乱跳的,比如学生可以把自己订单改成 4。

实现时建议把状态定义成整数常量,不要散落在控制器里。比如:

public class OrderStatus { public static final int PENDING_REVIEW = 0; public static final int APPROVED = 1; public static final int SUMMARIZED = 2; public static final int READY = 3; public static final int DISTRIBUTED = 4; public static final int CANCELED = 5; }

为什么不用字符串?因为数据库里字符串可读性差,而且很容易出现"approved "这种带空格的数据。整数字段配合枚举说明表,方便 SQL 统计。状态流转的更新语句不能直接无脑UPDATE,必须带上“当前状态”条件,比如审核通过时写UPDATE orders SET status = 2 WHERE id = ? AND status = 1,如果影响行数为 0,说明订单已经被改动,要回滚并提示用户刷新。这就是乐观锁最简单的形变,也是答辩时值得讲的点。下面这个对照表可以放在你的设计说明书里:

status含义可执行的操作
0待审核学生取消,教师驳回/通过
1已通过管理员汇总
2已汇总管理员入库
3待领取管理员发放
4已发放只读
5已取消只读

2.3 核心数据表设计:教材表、订单表、订单明细表的关系

先给一个我常用的最小表结构,基于 MySQL。这套表能覆盖上面状态机,也够你做统计和导出:

CREATE TABLE t_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, real_name VARCHAR(30), role TINYINT NOT NULL DEFAULT 1, class_name VARCHAR(50), student_no VARCHAR(30), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE t_textbook ( id BIGINT AUTO_INCREMENT PRIMARY KEY, isbn VARCHAR(20), textbook_name VARCHAR(100) NOT NULL, author VARCHAR(50), publisher VARCHAR(80), price DECIMAL(10,2), stock INT DEFAULT 0, semester VARCHAR(20), status TINYINT DEFAULT 1 ); CREATE TABLE t_order ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(40) NOT NULL UNIQUE, student_id BIGINT NOT NULL, reviewer_id BIGINT, status TINYINT NOT NULL DEFAULT 0, total_price DECIMAL(10,2), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, review_time DATETIME ); CREATE TABLE t_order_item ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id BIGINT NOT NULL, textbook_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 1, price DECIMAL(10,2) );

为什么订单要拆主表和明细表?因为一个订单可能订多本教材。如果只做一张表,一行数据同时包含订单信息和教材信息,统计汇总会写出一堆重复列,而且改订单状态时要去改多行,容易出错。主从表是这套系统的地基,也是后端开发里“一对多”的典型例子。你甚至可以拿着这张表去回答“为什么不用 MongoDB”,因为订单和明细需要事务一致性,关系型数据库更稳。

注意几个细节。order_no必须唯一,而且不要用自增 id 当订单号给学生看,否则很容易被猜出订单量。常见做法是yyyyMMddHHmmss + 随机数,或者加入用户 id 前缀。price建议在生成订单时快照进t_order_item,而不是每次显示时去查教材表的当前价格,因为学期中途教材调价,历史订单不能被影响。索引方面,t_order.student_id、t_order.status、t_order_item.order_id这三个是查询热点,如果数据量超过几千行,建议各加一个普通索引,但不要加物理外键,理由我在第 5 章会详细说。这一套表设计做完,你的项目文档里“数据库设计”这一节就已经有东西可写了。

3. 从 ZIP 到跑通:靠 10 分钟判断技术栈,再一步步启动项目

这一步是所有毕设项目的“鬼门关”。很多人拿到压缩包后,直接双击 exe 或点开源码目录里的 .java 文件,然后就开始喊“缺少 JDK”“不支持发行版本”“找不到包”。实际上,任何 Java 方向的毕设源码都有固定套路:先看构建文件和数据库脚本,再决定启动方式。哪怕你是第一次拿到别人写的项目,按这套流程走,10 分钟能定位到技术栈,半小时内大概率能跑起来。

我先说一个通用检查命令。假设你已经在终端里解压了CollegeTextbookOrderingManagementSystem.zip,先做这几步:

unzip CollegeTextbookOrderingManagementSystem.zip ls -la find . -maxdepth 3 -type f \( -name "pom.xml" -o -name "web.xml" -o -name "package.json" -o -name "*.sql" -o -name "README*" \)

第一行是解压,第二行看根目录结构,第三行在项目目录里找关键文件。find命令里的-maxdepth 3是防止它挖得太深扫出一堆无用的依赖文件。找到的那些文件,就是你判断技术栈的依据。

3.1 判断技术栈:pom.xml、web.xml、package.json 各代表什么

先看根目录有没有pom.xml。有它说明是 Maven 项目,Java 后端大概率是 Spring Boot,也可能是 SSM 的 Maven 化工程。再看有没有package.json,有它说明前端是独立工程,需要先npm install。如果哪个都没有,但存在src/main/webapp/WEB-INF/web.xml,那一般是传统的 Servlet + JSP 项目,手动配置,部署到 Tomcat 的 webapps 下即可。

这三种情况启动方式完全不同。Spring Boot 项目直接用mvn spring-boot:run或者在 IDE 里运行带main方法的类;SSM 的 Maven 项目要先打 war 包再丢给 Tomcat;JSP 老项目可能连 Maven 都没有,是直接把整个 webapp 目录放进 Tomcat。这里最坑的是“毕设源码里既有 pom.xml 又有 web.xml”,那多半是 SSM 框架打 war 包,既不能用java -jar,也不能只点运行类。

我的习惯是优先看 README。毕设的 README 通常写着数据库名、默认账号、启动顺序。没有 README 也没关系,用find找到.sql文件,然后执行下一步。你不需要完全读懂每个文件的源码,只要判断出“这是什么框架、数据库怎么初始化、用哪个端口启动”,就能往前走。

3.2 初始化数据库:按依赖顺序导入 SQL 脚本

绝大多数毕设会附一个.sql文件,有的把建表和插入数据放在一起,有的拆成college_textbook.sql和data.sql。导入前先建立数据库,建议用 utf8mb4 字符集。命令如下:

mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS textbook_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" mysql -u root -p textbook_db < college_textbook.sql

第一句创建数据库,第二句把 SQL 导入。参数说明:-e是执行一条 SQL;<是重定向文件到 mysql 客户端;utf8mb4是为了中文不乱码,也能避免 Linux 下导入 latin1 导致查询全是问号。如果你用的是图形化数据库工具,也可以直接运行 SQL 文件,但导入时字符集别选默认的 latin1。

如果 SQL 文件开头自带CREATE DATABASE,就不要在命令行里指定textbook_db <,否则会重复建库或者导到别的库里。如果拆了多个文件,注意依赖顺序:先用户表,再教材表,最后订单表。导入后一定要检查:

USE textbook_db; SHOW TABLES; SELECT COUNT(*) FROM t_user; SELECT COUNT(*) FROM t_textbook;

常见坑是 SQL 脚本里有DROP TABLE,重复导入会把已有测试数据清空。毕设阶段这不是大问题,但你在后面调试时如果再导一次,之前手动填的数据就没了,所以把导入动作当作“对数据库做一次初始化”来理解,别以为它只是加几张表。

3.3 浏览器、Tomcat 与端口:第一次启动的完整顺序

技术栈不同,启动方式也不同。如果是 Spring Boot 项目,改好数据库配置后,运行主类或者执行mvn spring-boot:run就行。如果是 SSM/JSP 项目,需要把项目部署到 Tomcat。我的建议是先看application.properties或jdbc.properties,把数据库账号密码改成本地的,然后再启动。

给一个 Spring Boot 的通用命令序列:

cd 项目目录 mvn clean package -DskipTests java -jar target/xxx.jar

-DskipTests是跳过测试,毕设很多测试类依赖数据库,跑不通很正常;target/xxx.jar的实际文件名看构建输出,有的是college-textbook-0.0.1-SNAPSHOT.jar。如果本地没有 Maven,也可以用 IDE 直接点绿色按钮启动,效果一样。启动成功后会看到类似Tomcat started on port(s): 8080的日志,说明后端起来了。

SSM 或 JSP 项目则麻烦一点。Tomcat 部署的常见命令如下:

catalina.bat run # Windows 前台运行 ./startup.sh # Linux/macOS 启动 ./shutdown.sh # 停止

不要双击startup.bat后看窗口关不掉就强杀进程,先看日志。Tomcat 日志在logs/catalina.out或logs/localhost.log里。部署前先确认端口没被占用:netstat -ano | findstr :8080(Windows)或lsof -i:8080(Linux/macOS)。端口被占是新手最常遇到的事,开了好几个本地服务都没意识到。

3.4 登录页进不去:先分清是“前端丢了”还是“后端挂了”

启动后浏览器打开http://localhost:8080/,可能出现几种情况:404、空白页、永远转圈、直接报 500。先用命令行做一个快速体检:

curl -I http://localhost:8080/ curl -I http://localhost:8080/login tail -n 100 logs/catalina.out

如果curl -I返回 404,说明应用根本没部署到根路径,要么访问http://localhost:8080/项目名/,要么后端没起来。如果返回 200 但页面空白,多半是静态资源路径问题,参考第 5 章。如果tail日志里出现Unable to connect to database,那就是数据库连接问题,回到 3.2 重新检查账号密码和 URL。这一套流程走下来,能排除掉 80% 的启动问题,剩下 20% 就是版本和依赖问题,我们放到避坑章节里细说。

4. 三个必改业务点:登录鉴权、教材订购与库存扣减的落地实现

项目能跑起来之后,真正的学习从改代码开始。我不建议把整个项目重写,但有三处必须看懂、最好自己动手调一遍:登录鉴权、订单提交、库存扣减。这三个点分别对应权限控制、事务边界和并发安全,也是答辩时最容易挨个被追问的地方。很多同学把毕设跑通就完事了,结果老师问“订单提交的时候库存是怎么扣的”就开始支支吾吾。这一章我把每个点拆成最小代码片段,你可以对照自己的项目改。

4.1 登录鉴权:用拦截器统一处理,而不是每个页面写 if

先讲原理。控制器里每个方法都写if (session.getAttribute("user") == null) return "login";不是不行,但代码冗余,而且接口如果返回 JSON,还要重复写状态码。常见做法是写一个HandlerInterceptor,只拦需要登录的 URL,放行登录页、静态资源、注册接口。下面这段是 Spring Boot 风格,SSM 里叫HandlerInterceptor,Servlet 里叫Filter,思路一样:

@Component public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口和静态资源,避免登录页自身被拦截 if (request.getRequestURI().contains("/login") || request.getRequestURI().startsWith("/static/") || request.getRequestURI().startsWith("/css/") || request.getRequestURI().startsWith("/js/")) { return true; } Object user = request.getSession().getAttribute("loginUser"); if (user == null) { if (request.getRequestURI().startsWith("/api/")) { response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"未登录\"}"); } else { response.sendRedirect("/login"); } return false; } return true; } }

逻辑说明:preHandle返回true表示放行,返回false表示拦截。request.getRequestURI()判断当前路径,登录页自己的login必须放行,否则会死循环。/api/前缀的判断很实用:前端异步请求希望拿到统一的 401 JSON,而普通页面跳转则直接重定向。你还要在配置里注册这个拦截器,指定拦截路径和放行路径。

为什么强调角色判断别写在页面里?因为接口工具可以直接构造请求包,绕过前端按钮。拦截器里可以通过loginUser.role判断角色,但更干净的做法是做一个自定义注解@RequireRole(role = 2),用 AOP 校验。如果毕设时间紧,先做“登录拦截”就够了,把“角色拦截”留作加分项。

4.2 学生提交教材订购单:事务边界与库存预占

这是系统里最核心的接口。业务逻辑:校验学生信息、查询教材、计算总价、生成订单主表和明细表、扣减库存。必须放在一个事务里。如果扣库存成功但生成订单失败,库存不能少;反过来,订单生成成功但库存没扣,期末就对不上账。下面这段代码是简化的 Service 方法:

@Transactional(rollbackFor = Exception.class) public Long createOrder(Long studentId, List<OrderItemVO> items) { // 1. 生成唯一订单号 String orderNo = "TX" + System.currentTimeMillis() + String.format("%04d", new Random().nextInt(10000)); // 2. 创建订单主表 Order order = new Order(); order.setOrderNo(orderNo); order.setStudentId(studentId); order.setStatus(OrderStatus.PENDING_REVIEW); BigDecimal total = BigDecimal.ZERO; // 3. 明细与库存校验 for (OrderItemVO vo : items) { Textbook tb = textbookMapper.selectById(vo.getTextbookId()); if (tb == null || tb.getStatus() != 1) { throw new BusinessException("教材不存在或已下架"); } if (tb.getStock() < vo.getQuantity()) { throw new BusinessException("库存不足:" + tb.getTextbookName()); } // 快照价格,后续调价不影响历史订单 OrderItem item = new OrderItem(); item.setTextbookId(tb.getId()); item.setQuantity(vo.getQuantity()); item.setPrice(tb.getPrice()); order.getItems().add(item); total = total.add(tb.getPrice().multiply(BigDecimal.valueOf(vo.getQuantity()))); // 扣减库存,注意条件是 stock >= quantity int rows = textbookMapper.deductStock(tb.getId(), vo.getQuantity()); if (rows == 0) { throw new BusinessException("教材库存已变化,请重新下单"); } } order.setTotalPrice(total); Long orderId = orderMapper.insert(order); order.getItems().forEach(item -> item.setOrderId(orderId)); orderItemMapper.insertBatch(order.getItems()); return orderId; }

这段代码有几个关键点。@Transactional(rollbackFor = Exception.class)保证全部成功或全部回滚,Spring 默认只回滚运行时异常,如果你的BusinessException是编译期异常,不写这个参数就会“事务失效”。deductStock的 SQL 必须是:

UPDATE t_textbook SET stock = stock - #{quantity} WHERE id = #{id} AND stock >= #{quantity}

这比“先查出来,减一,再写回去”安全得多,因为并发时两个请求同时读到库存 5,各减 1,最终可能变成 4 或 3,而不是正确的 3。条件更新让数据库在原子操作里做判断,影响行数为 0 就说明库存被抢了。价格快照也是个容易忽略的点:tb.getPrice()存进明细,之后教材涨价不影响这笔订单。

4.3 教材管理员审核与入库:状态更新与库存补货分开控制

订单状态变化要收敛到少数几个 Service 方法,不要每个控制器里都写UPDATE。我的做法是提供approveOrder(orderId, reviewerId)、cancelOrder(orderId)、stockIn(textbookId, quantity)方法。其中入库是独立操作,因为教材到货后可能要补入库存,跟学生订单的扣减是两回事。示例:

@Transactional(rollbackFor = Exception.class) public void stockIn(Long textbookId, int quantity) { if (quantity <= 0) { throw new BusinessException("入库数量必须大于0"); } // 原子加库存,不先查再算,避免并发覆盖 int rows = textbookMapper.increaseStock(textbookId, quantity); if (rows == 0) { throw new BusinessException("教材不存在"); } }

increaseStock对应 SQL 是UPDATE t_textbook SET stock = stock + #{quantity} WHERE id = #{id}。为什么用原子加而不是先查再写?因为并发入库时,两个事务都读到 10,各加 5,后写的一方会覆盖前一方,库存变 15 而不是 20。原子加在数据库层面解决这个问题。很多同学在讲库存的时候只会说“同步锁”,其实 MySQL 行锁和乐观锁已经够用,答案更扎实。

审核订单的状态更新也要注意。审核通过时,应该写UPDATE t_order SET status = 1, reviewer_id = ?, review_time = NOW() WHERE id = ? AND status = 0,如果影响行数为 0,说明这个订单已经在别的入口被处理了。这样才能保证状态机不会被跳步或重复审核。

4.4 给 Controller 加一层参数校验:少写很多空指针

最后一个很实用的小习惯:Controller 层先做基础校验,别让脏数据直接进 Service。毕设项目里很多空指针都是因为前端传了null、空字符串、负数。示例:

@PostMapping("/order") public Result createOrder(@RequestBody OrderCreateDTO dto) { if (dto.getStudentId() == null || dto.getItems() == null || dto.getItems().isEmpty()) { return Result.fail("参数不能为空"); } Long orderId = orderService.createOrder(dto.getStudentId(), dto.getItems()); return Result.ok(orderId); }

这段代码没有技术难度,但能减少大量排查时间。如果时间充裕,可以用@Validated注解配合@NotNull、@Positive做声明式校验,答辩时还能多说一句“我用了 Bean Validation”。

5. 毕设避坑:跑不起来的常见问题与排查清单

下面列的这四条,是我看过很多同类毕设项目后总结出来的高发坑。每一条都按“现象 → 原因 → 解决”来写,你自己遇到时可以直接对照。这些坑不复杂,但往往一卡就是几个小时,而且越着急越找不到,属于典型的“翻车点”。

5.1 数据库连不上:版本、驱动、时区与字符集的连环坑

现象:项目启动后控制台报错Connection refused,或者Unknown initial character set index '45',或者The server time zone value '???ó??' is unrecognized。有时候是登录后页面报 500,后台日志写着Public Key Retrieval is not allowed。

原因:MySQL 8 换了驱动com.mysql.cj.jdbc.Driver,项目里还在用老驱动com.mysql.jdbc.Driver;连接地址没加时区参数;本地 MySQL 版本和项目依赖版本不匹配。这三样经常一起出现,所以新手会以为是“玄学”,其实是配置没对齐。

解决:在application.properties里改成下面这行配置,基本能通杀:

spring.datasource.url=jdbc:mysql://localhost:3306/textbook_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true spring.datasource.username=root spring.datasource.password=你的密码 spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

allowPublicKeyRetrieval=true是 MySQL 8 配合caching_sha2_password插件需要的,很多人只在报错时才加,其实可以提前加上。改完后,先在命令行执行一次mysql -u root -p -e "select 1;",确认数据库本身是通的,再去怀疑代码。数据库连接问题占毕设启动失败的 40%,这一步必须最先排查。

5.2 页面样式全丢或登录后跳不进主页:绝对路径与上下文路径

现象:浏览器打开登录页,CSS 和 JS 全挂,控制台一堆 404;登录成功后跳转http://localhost:8080/student/index,但 URL 始终卡在/login来回跳。

原因:项目部署到 Tomcat 后带了应用名,比如http://localhost:8080/textbook/,但页面里写的是<link href="/css/style.css">,根路径定位到了http://localhost:8080/css/style.css,自然找不到。登录跳转也一样,sendRedirect("/index")会丢掉应用名。

解决:不要硬编码根路径。JSP 页面里用 JSTL 或${pageContext.request.contextPath}拼接,比如:

<link href="${pageContext.request.contextPath}/css/style.css" rel="stylesheet">

Java 后端跳转时也用request.getContextPath()拼。如果是 Vue 工程,配置路由的 base 和后端代理。这个坑最常见的触发时机是:本地 IDE 集成 Tomcat 时能跑,换到外置 Tomcat 部署就全乱。你在答辩前一定要用外置 Tomcat 跑一遍,提前踩掉这个坑。

5.3 订单重复提交:同学在页面上多点了两下

现象:学生提交教材订购单时双击按钮,数据库里出现了两条一模一样的订单;或者订单明细翻倍,库存扣了两次。前端看起来没反应,其实后端已经处理了第一次请求。

原因:前端没有禁用按钮,后端也没有幂等控制。简单项目没有 Redis,但至少可以在数据库层做唯一约束,或者在业务上做“防重”。

解决:在t_order表按“学生 + 学期”做限制。比如提交前先查一下:

int count = orderMapper.countPending(studentId, currentSemester); if (count > 0) { throw new BusinessException("你还有未审核的订单"); }

或者给t_order加一个student_id + semester联合唯一索引。这两种方案选一个就好。真正完整的幂等控制需要“幂等请求号”,也就是前端每次提交带一个唯一 token,后端记录已经用过的 token,重复提交直接拒绝。毕设做到“防重”已经足够讲,比完全没有强很多。

5.4 物理外键导致导入失败,或删除用户被外键拦住

现象:导入 SQL 时明明按了顺序,还是报Cannot add foreign key constraint;删除某个测试账号时提示foreign key constraint fails。

原因:表结构里有FOREIGN KEY,但导入顺序不对,或者 MySQL 的外键检查和预期不一致。

解决:毕设项目我建议不建物理外键,保留普通索引,在 Service 层保证引用关系。理由有三个:物理外键在导入测试数据、初始化系统时会增加额外限制;答辩老师更关注业务逻辑外键;很多公司的生产库也会禁用物理外键。如果你已经遇到这个问题,可以临时关闭外键检查:

SET FOREIGN_KEY_CHECKS=0; -- 执行你的导入或删除操作 SET FOREIGN_KEY_CHECKS=1;

注意这个命令只对当前会话有效,别写死在 SQL 文件里,否则后患无穷。真正需要某张表不能随便删除时,用“软删除”字段比外键更实用。

6. 把“能跑”改成“能答辩”:验证、加固与三个加分项

项目跑通、改完核心逻辑,剩下的工作不是写注释,而是做一轮可复现的验证和几个能被问住的加固点。我习惯先列一张冒烟测试清单,把 6 个核心流程走一遍:学生登录、浏览教材、提交订单、教师审核、管理员入库、学生查看状态。每走完一步,就检查数据库里对应的状态和库存数字,而不是只看页面提示。这样做能发现“页面显示成功但数据没变”的假成功。

验证时最值得做的加固是“后悔药”:在关键操作上打印日志。用日志记录谁在什么时间改了哪个订单,绝对会让答辩老师多给你二十分钟的讲述耐心。简单做法是加一个日志切面:

@Aspect @Component public class OperationLogAspect { @Around("@annotation(OperationLog)") public Object around(ProceedingJoinPoint pjp) throws Throwable { long start = System.currentTimeMillis(); Object result = pjp.proceed(); long cost = System.currentTimeMillis() - start; logger.info("{} 执行耗时 {}ms", pjp.getSignature(), cost); return result; } }

这个切面不完整,但足够展示“我关注系统可观测性”。然后是三个答辩必问点的应答思路:幂等,讲订单号唯一和防重;并发,讲条件更新和原子加;权限,讲登录拦截器和注解校验。把这三句话放进你的设计说明,比贴一整页代码更能回答“这套系统的难点是什么”。我最早做过一个购物逻辑,库存扣减直接写成UPDATE stock = stock - 1,没加条件,测试时两个页面同时提交,库存变负数,被导师一眼盯上。那之后我只要碰写路径,就默认加条件更新。教材订购这种题目最适合练这个,因为它把并发、状态机、权限都放在一个真实业务里。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进