1. 为什么我最终选择用 Servlet 手写一套在线票务系统,而不是直接上 Spring Boot
接到这个题目的时候,我的第一反应和大多数同学一样:都什么年代了,还在用 Servlet 写 Web 项目?Spring Boot 不香吗?但真的动手把"基于 Java Web(Servlet 框架)的在线票务系统"完整做下来之后,我才发现这个选择其实相当有讲究。这套技术栈虽然看起来"复古",但在教学评估、底层原理理解和答辩表达三个维度上,都有框架封装替代不了的价值。
1.1 项目约束与选型理由
先说说这个项目的来由。在线票务系统是一个非常典型的课设/毕设题目,需求覆盖用户注册登录、场次票档展示、下单购票、订单管理、后台数据维护等完整闭环,数据模型和业务流程都足够丰满。而题目明确要求使用 Servlet 框架,这其实不是限制,反而帮我们划定了技术边界:不需要引入 Spring、不需要微服务,所有 HTTP 请求处理、会话管理、数据库访问都由我们自己手写掌控。
我当时的完整技术栈是:JDK 8 + Tomcat 9 + MySQL 8.0 + JSP/JSTL + JDBC,前端用 Bootstrap 做了简单样式,JSON 序列化用的 Gson,构建工具用的 Maven。这套组合里没有一件是"框架级"的重量级选手,每一层都看得见、摸得着。Tomcat 接收到一个 HTTP 请求后,从 Servlet 的 doGet/doPost 进入,到我们自己写的 DAO 查询数据库,再原路返回渲染到 JSP 页面上,整条链路没有任何黑盒。
从一个过来人的角度看,这种"裸写"项目带来的最大收获,是你被迫去理解 HTTP 协议、Servlet 生命周期、Session 机制、JDBC 事务这些最底层的东西。比如你手写过一个 Connection 手动 commit/rollback 的 Service 方法之后,再去看 Spring 的 @Transactional 注解,你会知道它背后替你干了什么;你手动配置过 Filter 做登录拦截之后,再去看 Spring MVC 的拦截器,你会一眼理解它的定位。所以如果你现在正在被这个题目折磨,别急着抱怨,这套东西做完,你的 Java Web 基础会非常扎实。
1.2 Servlet + JSP 这条技术栈到底够不够用
很多同学担心的问题其实是:用 Servlet 写的在线票务系统,功能上会不会很"简陋"?我的实测结论是:完全够用,前提是你规划好分层。
在线票务系统的真实业务复杂度并不高,它不像电商平台那样需要海量 SKU 和复杂的促销规则,核心就集中在"场次—票档—库存—订单"这条线上。Servlet 负责接收请求、调用 Service、转发页面,JSP 负责渲染视图,JDBC 负责数据持久化,三层各司其职之后,系统可以非常清爽地跑起来。我最终完成的系统支持用户注册登录、首页场次列表、场次详情与票档余票展示、提交订单(带库存扣减)、我的订单查询、取消订单、后台场次与票档管理,演示和答辩都完全拿得出手。
真正的瓶颈在于并发场景。Servlet 默认是单实例多线程的,也就是说多个用户同时抢票时,你的业务代码会并发执行,这里一旦处理不当就会出现超卖。但这恰恰是这个项目最有价值的部分——你得亲手解决并发问题,而不能像用 Spring Boot + MyBatis 那样抄一个乐观锁注解就完事。按我的经验,只要把数据库并发控制和事务处理写对了,这套系统承载课设和演示级别的并发量绰绰有余。
2. 表结构设计:从用户到订单,五个关键表怎么支撑一次完整购票
数据库是整个在线票务系统的地基。我见过很多同学一上来就建了十几张表,看起来很高大上,结果字段重复、状态冗余、查询逻辑绕来绕去。票务系统不是 ERP,它的核心数据关系其实非常收敛。我最终只用了五张核心业务表,就完整跑通了购票流程。
2.1 用户、场次、票档三张基础表
第一张是用户表 user,字段包括 user_id、username、password、phone、email、create_time。这里的 password 绝对不能存明文,我用的是加盐后的 SHA-256 哈希值,虽然现在业界更推荐 BCrypt,但在课程设计阶段自己实现一个加盐哈希也能讲清楚安全思路。username 要加唯一索引,这是登录的天然业务主键。
第二张是场次表 event,字段包括 event_id、event_name、venue、start_time、end_time、description、cover_url、status。这里的 status 我建议用 0/1 表示"未开始/已结束",不要用字符串,排序和查询都方便。start_time 和 end_time 用 DATETIME 类型,注意 JDBC 读取时要用 rs.getTimestamp(),如果只用 getDate() 会丢掉时分秒,这会导致场次时间显示不完整,我一开始就踩过这个坑。
第三张是票档表 ticket_type,这是整个设计的核心亮点。它把"一场演出有多少种票"建模成了一张子表,字段包括 ticket_type_id、event_id、type_name(比如"普通票""VIP票""早鸟票")、price、total_stock、sold_count。这里我特意没有做成一张"座位表",原因后面会讲,但对于票务系统的通用模型来说,"按票档卖票、每档有总库存和已售数"是最经典也最稳定的方案。
2.2 订单表与支付记录表:状态机这样流转
第四张是订单表 orders,字段包括 order_id、order_no、user_id、event_id、ticket_type_id、quantity、amount、status、create_time、pay_time、cancel_time。order_no 是给用户看的订单号,我生成规则是"时间戳 + 用户ID后四位 + 四位随机数",这样每个订单号都唯一且可读性强。status 字段是整个订单状态机的核心,我用 TINYINT 存了四个值:0 待支付、1 已支付、2 已取消、3 已完成。订单流转路径是:下单时必然是 0 待支付,用户点击支付后变 1,用户主动取消或者超时未支付变 2,活动结束后把已支付订单置为 3 已完成。
第五张是支付记录表 payment,字段包括 payment_id、order_id、pay_no、amount、status、callback_time。这张表的定位是记录每次支付行为,当用户对同一笔订单发起多次支付请求时,payment 表会产生多条记录,但只有成功的那个状态是 1。把订单表和支付记录表拆开的好处是:订单状态只管业务流向,支付是否到账由支付流水负责,两者不互相污染。
数据表之间用外键关联,但在实际项目里我建议仅仅在逻辑上关联,不要真的在 MySQL 层面加外键约束。因为加了物理外键之后,管理员删除一个场次时会被订单、票档等子表挡住,删除流程变得极其麻烦。我的做法是:建表时只加普通索引,根本不在 DDL 里写 CONSTRAINT FOREIGN KEY,所有表间一致性的维护交给 Service 层事务。这个取舍在答辩时完全可以讲清楚,面试官反而会认可你对数据库设计的理解。
3. 三层架构落地:DAO、Service、Servlet 的职责划分与代码拆解
表结构设计好之后,最怕的就是把代码全部堆在 Servlet 里。我见过太多同学在一个 doPost 方法里写几百行:连着数据库、拼 SQL、执行查询、手动组装 HTML 输出,那根本不叫 Web 项目,那是披着 Servlet 外壳的脚本。这个系统想要拿高分,三层架构的"层次感"必须清晰。
3.1 分包结构与数据访问层设计
我最终的项目包结构是这样的:entity 放实体类,dao 放数据访问层,service 放业务逻辑层,servlet 放控制层,filter 放过滤器,util 放工具类。实体类严格对应数据库表,属性名和字段名做驼峰映射,比如数据库里是 create_time,Java 里就是 createTime。要注意的是,实体类里我偶尔会加一些不属于表结构的字段,比如 Event 中加一个 ticketTypes 列表,方便页面一次展示场次下的所有票档,这个叫"视图组装字段",不算破坏分层。
DAO 层我只写了一个 BaseDAO 和各个实体的 XxxDAO。BaseDAO 里封装了通用的查询方法和更新方法:查询方法接收 SQL 和参数,返回 List ;更新方法接收 SQL 和参数,返回受影响行数。这里必须使用 PreparedStatement,绝对不能字符串拼接 SQL。我见过有人为了省事直接"WHERE id = " + userId,结果被 SQL 注入,登录接口被人用万能密码怼穿,这是整个项目中最低级的错误。PreparedStatement 的 ? 占位符不仅能防注入,还能让 MySQL 走预编译缓存,性能也更好。
3.2 Service 层事务边界在哪里
Service 层是整个系统的业务心脏,也是最容易讲清楚"为什么"的地方。我遵循的原则是:一个对外方法对应一个事务边界,方法开始时获取 Connection 并设置 setAutoCommit(false),方法成功结束后 commit,任何异常 catch 到之后 rollback,并且把 Connection 通过参数或 ThreadLocal 传递给 DAO 层。
最典型的场景就是下单购票。一次完整的下单操作包括三步:扣减票档库存、创建订单记录、如果有优惠则扣减优惠券。这三步必须处于同一个事务中,任何一步失败都要整体回滚。我特别强调这一点,是因为很多同学会在 Servlet 里逐条调用 DAO,每条 DAO 各自开一个连接执行,一旦第三步失败,前面的库存已经扣了,数据就永远对不上。后来我在 Service 层用一个 TransactionTemplate 手动管理事务,并在 finally 里关闭连接,问题彻底解决。
我这里给出一段我实际写过的核心下单伪代码框架,虽然做了简化,但事务的骨架是完整的:
public boolean createOrder(Order order, int ticketTypeId, int quantity) { Connection conn = DBUtil.getConnection(); try { conn.setAutoCommit(false); // 1. 乐观锁扣减库存,受影响行数为0说明余票不足或版本冲突 int rows = ticketTypeDao.deductStock(conn, ticketTypeId, quantity); if (rows == 0) { conn.rollback(); return false; } // 2. 插入订单记录 orderDao.insert(conn, order); // 3. 记录支付流水(待支付状态) paymentDao.insert(conn, order.getOrderId(), "PENDING"); conn.commit(); return true; } catch (Exception e) { conn.rollback(); log.error("下单失败", e); return false; } finally { DBUtil.close(conn); } }3.3 Servlet 控制层的请求分发与参数校验
控制层我再三提醒自己:Servlet 只做三件事——接收参数、调用 Service、决定返回哪个视图或 JSON。所有的参数类型转换、空值校验、业务异常处理,要么在 Servlet 里做轻量检查,要么丢给 Service 层抛业务异常,但绝对不允许出现 SQL 语句。我做了一个 BaseServlet 继承 HttpServlet,把 doGet 和 doPost 统一转发到同一个 handle 方法,再根据 request 里的 action 参数分发到具体业务方法。这样避免了每一个 Servlet 都要重写一遍 doGet/doPost 的样板代码。
参数校验是我在这个项目里做得比较细的地方。比如购买数量必须大于 0、不能超过单笔订单上限、手机号要满足正则、日期字符串要能解析成功。这些校验如果放在 JSP 页面做,用户体验好但不可靠;如果放在数据库层做,错误信息太粗暴。最合适的层级是 Servlet 层入口做格式校验,Service 层做业务校验(比如库存是否充足、场次是否开始),JSP 里只做前端提示。三层校验各有侧重,这个设计在答辩时能成为一个加分讲解点。
4. 抢票场景下的并发控制:彻底解决"库存被多卖"
在线票务系统最刺激的业务场景就是抢票。热门演唱会开票的瞬间,可能同时有几十上百个请求打到服务器。如果代码逻辑是"先查询余票,判断足够,再扣减库存",那么两个线程可能同时读到余票为 1,然后都执行扣减,最终 sold_count 变成 2,超过 total_stock,也就是超卖。这是我在测试阶段用并发工具反复验证过的问题,也是最值得写进博客的硬核内容。
4.1 经典超卖问题是怎么产生的
我们先看一段最容易写出 Bug 的伪代码:第一个步骤 SELECT sold_count FROM ticket_type WHERE ticket_type_id = ?;第二个步骤在 Java 里判断 sold_count + quantity <= total_stock;第三个步骤执行 UPDATE ticket_type SET sold_count = sold_count + quantity WHERE ticket_type_id = ?。这段逻辑在单线程下完全正确,一旦并发,两个请求同时执行完第一步,读到的都是同一个 sold_count,于是都通过了 Java 层的数量判断,第三步都执行成功,数据就错了。
这个问题的根源在于"检查和更新不是原子的"。Java 层的 if 判断和数据库层的 UPDATE 之间,存在一个时间窗口,并发请求可以全部挤进这个窗口。解决方案的核心思路只有一个:把"检查余票是否充足"和"扣减库存"合并成一个数据库层面的原子操作。
4.2 三种落地方案对比与最终选择
我在项目里对比了三种方案,最终选择了最适合 Servlet + JDBC 场景的那一种。
第一种是悲观锁方案,在查询票档时加上 SELECT ... FOR UPDATE,强制锁住这一行,其他事务必须等待。这个方案在并发量非常大时会形成行锁等待,而且需要事务包裹整个操作,锁的持有时间越长,吞吐量越低。课程设计阶段用它可以,但我没有选它。
第二种是乐观锁版本号方案,在 ticket_type 表加一个 version 字段,更新时要求版本号匹配并且同时递增。这个方案思路优雅,但是需要额外的字段维护,并在更新失败后做重试或提示,代码复杂度略高。
第三种是我最终采用的"条件更新"方案,核心 SQL 是:
UPDATE ticket_type SET sold_count = sold_count + ?, version = version + 1 WHERE ticket_type_id = ? AND sold_count + ? <= total_stock这条 SQL 用一条 UPDATE 完成了判断和扣减两个操作,数据库引擎在行锁的保护下保证原子性。如果影响行数为 0,说明余票不足,直接返回"票已售罄"。为了和乐观锁方案的思想结合,我也保留了 version 字段,并把 "AND version = ?" 加进 WHERE 条件,这样就同时做到了"业务判断原子化"和"并发更新冲突检测"。实测用并发模拟工具开 50 个线程同时抢同一场次的最后 10 张票,最终成功创建的订单正好是 10 条,sold_count 也从没错过。
4.3 订单超时未支付怎么处理
解决了超卖,下一个问题是:用户下单后不支付怎么办?总不能让库存一直锁着。我采用的方案是"下单锁库存 + 定时任务扫描释放"。下单时把订单状态置为待支付,并记录 create_time;后台用 ScheduledExecutorService 启动一个每 30 秒执行一次的定时任务,扫描所有状态为待支付且创建时间超过 15 分钟的订单,将其置为已取消,同时回补对应票档的库存,也就是反向执行 sold_count = sold_count - quantity。
这里有一个很重要的细节:回补库存的 UPDATE 必须也要带条件,防止订单已经被用户支付了但定时任务还没执行到的情况。我加了一个前置条件:"UPDATE orders SET status = 2 WHERE order_id = ? AND status = 0",如果这行更新影响数为 0,说明订单状态已经不是待支付,就不能回补库存。这个"状态变更先比较旧状态"的写法,是防止脏数据覆盖的经典手段,也体现了数据库层面对并发操作的兜底意识。
5. 登录态、权限拦截与密码安全:Session 和 Filter 的日常用法
在线票务系统里有用户角色和后台管理角色,权限控制是躲不开的需求。用 Servlet 做这个,核心就两个工具:Session 和 Filter。但用不好就会出现"登录了还能直接访问下单接口""密码明文存储""退出登录后 Session 没销毁"之类的低级问题。
5.1 注册登录与密码存储
注册功能里,密码绝对不能存明文。我实现的方式是:在 util 包里写一个 PasswordUtil,用"固定盐 + 用户名字 + 密码"拼成一个字符串,再做 SHA-256 哈希,最终把哈希值和盐一起存到数据库。虽然这个方案和专业的 BCrypt/Argon2 相比强度偏弱,但在教学项目里你能讲清楚"为什么不能存明文""加盐的作用是什么",就已经达到目的了。登录时取出数据库里的盐,对用户输入的密码做同样的哈希,再比较结果。
登录成功之后,我把 user 对象和角色信息放进了 Session,关键代码是:
session.setAttribute("loginUser", user); session.setAttribute("role", user.getRole());Session 的底层其实就是 JSESSIONID 这个 Cookie,Tomcat 会为每个新会话生成一个不重复的 ID,客户端之后每次请求都会带上它,服务端据此找到对应的 HttpSession 对象。只要你不主动调用 session.invalidate(),这个会话默认有 30 分钟的有效期。
5.2 用 Filter 做统一登录拦截
权限拦截如果用每个 Servlet 里 if 判断实现,会重复且容易漏。正确做法是写一个 LoginFilter 实现 javax.servlet.Filter,在 doFilter 里先判断请求路径,如果是登录页、注册接口、静态资源(css/js/images),直接放行;否则从 Session 里取 loginUser,如果取不到,就重定向到登录页。后台管理相关的路径再额外加一个角色判断,不是 admin 角色就返回 403 页面。
Filter 的过滤顺序由 web.xml 中的 filter-mapping 顺序决定,我配置了三个 Filter:第一个是 CharacterEncodingFilter,强制所有请求和响应使用 UTF-8 编码;第二个是 LoginFilter,做登录拦截;第三个是 AdminFilter,只拦截 /admin/* 路径。顺序很重要,如果登录拦截放在编码过滤器前面,POST 请求的中文参数就会乱码。这个顺序问题我排了很久才定位到,后来验证发现 Filter 的执行顺序和 J2EE 规范一致,先声明的先执行,编码过滤器必须先于业务过滤器。
5.3 会话安全要防的几个点
除了基础登录拦截,我还加了两个安全细节。第一个是退出登录时不能只 removeAttribute("loginUser"),必须调用 session.invalidate(),这样 JSESSIONID 直接失效,别人拿着旧 Cookie 也无法继续访问。第二个是针对下单接口的 CSRF 简单防护:在用户访问订单确认页时生成一个一次性的随机 token 放入 Session,前端提交订单时把 token 一起提交,Service 层校验 Session 中的 token 是否一致,校验通过后立即清除。因为跨站请求无法读取到我们页面里的 token,这个方案可以抵挡绝大多数简单的跨站伪造请求。
这些安全措施不需要做到银行级别,但"明文密码""登录态可伪造""未登录可下单"这三个雷绝对不能踩。答辩时老师最爱问的也是这里,你能讲出 Session 失效、Filter 拦截范围、CSRF Token 这三个点,安全设计这一关基本就稳了。
6. 页面交互:JSP + AJAX 做出票档选择、下单与倒计时体验
票务系统的前端不需要花里胡哨,但交互链路要顺。我用的是 JSP 渲染主要页面结构,配合 jQuery + AJAX 做局部刷新。这套方案的好处是:整个渲染过程还在服务端掌控中,符合题目对 JSP 的要求,同时 AJAX 能提供接近现代 Web 应用的体验。
6.1 JSP 页面里的 Java 代码该怎么控制
刚上手 JSP 的人最容易犯的错,是把 Java 代码像 JS 一样随意写在 <% %> 脚本里,导致 JSP 页面几百行 scriptlet,维护成本爆炸。我做了一个硬性约束:任何业务逻辑、数据库查询、if/for 循环全部在 Servlet 里完成,Servlet 把结果封装成 request 属性或转发参数,JSP 页面里只用 EL 表达式和 JSTL 标签渲染。
典型的详情页写法是这样的:Servlet 查询场次信息和对应票档列表,塞进 request 后 forward 到 event_detail.jsp。JSP 里用 c:forEach 遍历票档列表,每一个票档渲染成一张卡片,显示名称、原价、余票和购买按钮。这种写法看着干净,答辩时也容易解释"MVC 的分层体现在哪里"——JSP 只是 View,不承担任何业务职责。
6.2 AJAX 实时刷新余票与下单流程
余票数量是动态变化的,如果页面写死一个数字,用户看到"有票"下单时可能已经没了。我在票档卡片上加了一个"查余票"的 AJAX 请求,用户进入详情页时和点击购买时,都会调用一个 GetTicketStockServlet,它返回 JSON 数据:{ "code": 200, "stock": 23 }。前端拿到这个数字后实时更新按钮文案。为了不让用户频繁点击造成压力,我在前端做了一次简单的节流:两次请求之间的间隔不小于 2 秒。
下单交互我设计成"确认弹层 + 倒计时":用户选好票档和数量,点击购买后,弹出一个确认层,里面展示订单金额,同时启动 15 分钟倒计时。前端把这个倒计时展示出来,实际上最终是否超时以后端定时任务为准,前端只是给用户一个直观的预期。点击确认后,AJAX 发起 POST 请求到 CreateOrderServlet,携带票档 ID、数量、CSRF token。Servlet 返回 JSON,code 为 200 就跳转到"我的订单"页面,code 为 500 就弹出错误提示,比如"余票不足,请重新选择"。
这个链路现在看起来不复杂,但它把"服务端渲染列表 + 局部刷新数据 + 卢API式下单"三种模式都覆盖了,说实话已经能代表大多数中小型 Web 应用的交互形态。做完这一套,再去看前后端分离方案的 AJAX 交互,你会发现核心思想完全一致。
7. 部署实测与折腾记录:Tomcat、MySQL、乱码与并发测试
功能写完之后,真正让人头大的反而是环境、部署和测试。我把这一路折腾中遇到的高频问题整理成清单,每一个都是我亲手修过的,你可能也会碰到。
7.1 环境搭建里最容易踩的坑
第一坑是 MySQL 8.0 的 JDBC 连接串。必须带上时区参数,否则驱动会报 CST 时区不识别错误。我的完整连接串是 jdbc:mysql://localhost:3306/ticket_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8。useSSL 不加在本地没问题,但加上更规范;characterEncoding=utf8 是保证写入中文不乱码的关键。
第二坑是 Tomcat 版本和 Servlet API 包名的对应。Tomcat 10 及以上的实现包名从 javax.servlet 改成了 jakarta.servlet,很多网上教程还在用 javax,直接导入项目会编译报错。我的建议是课程设计选 Tomcat 9,和绝大多数教材、博客一致,遇到问题好查资料。
第三坑是数据库连接不关闭导致连接耗尽。这个一般发生在 DAO 层某个方法里 conn、ps、rs 没用 try-with-resources,异常时没有关闭。我在测试阶段亲眼看着系统从正常变成"响应 10 秒以上",查看 MySQL 的 max_connections 直接打满。后来把所有连接资源全部改成 try-with-resources 或 finally 关闭,问题消失。这个习惯比任何框架配置都重要。
第四坑是中文乱码三件套:JSP 页面顶部设置 pageEncoding="UTF-8"、CharacterEncodingFilter 强制 request 和 response 编码、MySQL 连接串带 characterEncoding=utf8。三处全对齐之后,从页面输入到数据库存储再到页面回显,中文全程无乱码。
7.2 并发测试验证不超卖
功能测试通过后,我用国产开源压测工具模拟了并发抢票场景。测试步骤是:把某场次某票档的总库存设为 10,然后用 20 个并发线程同时发送下单请求。跑完之后我做了三件事:一是查 orders 表里该票档对应的成功订单总数,结果正好 10;二是查 ticket_type 表的 sold_count,结果还是 10,没有超过 total_stock;三是查支付记录表,发现第 11 个及之后的下单请求返回的都是"票已售罄"。
这个结果说明条件更新 SQL 生效了。为了看得更直观,我还在 Service 层加了一条临时日志,把每次扣减库存后的受影响行数都打印出来,调试时可以清楚地看到哪些请求更新失败、什么原因失败。如果你们在做类似项目时发现偶尔还是超卖,我建议先检查两点:扣减库存的 UPDATE 是否确实带了余票判断条件,以及 Service 方法是否在同一个事务中。只要这两点没问题,超卖基本不会发生。
7.3 演示答辩前我梳理的"请求走查图"
最后分享一个我认为性价比极高的准备动作:画一张"一个请求从浏览器到数据库再返回"的完整走查图,然后对着它练习讲解。不需要什么绘图工具,一张白纸一支笔就能做。我的走查图涵盖了三个场景:用户注册登录、用户购买票务、后台管理修改场次。
比如用户购买票务,我会这样讲:浏览器发起 POST 请求,Tomcat 按 URL 映射找到 CreateOrderServlet;CharacterEncodingFilter 先统一编码;然后框架判断 Session 中是否有登录用户;进入 Servlet 的 doPost 后取出参数调用 OrderService;OrderService 开启事务,先执行条件更新扣减库存,再插入订单,成功后提交;Servlet 把结果封装成 JSON 字符串返回;前端 JS 收到 code=200 后跳转到订单页面。这一套讲下来,既体现了你对 HTTP 协议的理解,也体现出你对事务和并发控制的认识,比直接展示代码效果要好得多。
这套系统做完,我最大的体会是:用 Servlet 手写在线票务系统,本质上是一次"拆解黑盒"的过程。你亲手实现了框架帮你做过的每一件事——请求映射、会话管理、事务控制、JSON 序列化,然后再回头看任何 Java Web 框架,都不会再觉得它神秘。如果你正在做类似的课设,别急着追求炫技,把业务走通、把并发控制做对、把每一层为什么这么设计讲清楚,就已经是一份相当有分量的作品了。最后再补一句真心话:项目里那个 15 分钟未支付自动关单的定时任务,建议一定要做上,答辩演示时现场展示"下单不付款、库存自动回补",这个亮点比任何花哨页面都管用。