news 2026/9/20 7:02:11

Spring Boot网上书城实战:从数据库设计到订单事务与定时任务

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot网上书城实战:从数据库设计到订单事务与定时任务

简介:基于Spring Boot的网上书城网站Java毕业论文文档,面向计算机专业毕业生、正在设计在线图书销售系统的学生,以及需要参考Spring Boot整合MySQL/Eclipse开发流程的开发者。文档从研究背景、研究现状切入,明确系统化、规范化与自动化,降低维护工作量,优化查询与管理效率,通过网络操作提升处理速度,并保持界面简洁易用等开发目标;随后完整覆盖Java与MySQL简介、Spring Boot及Eclipse开发环境、需求分析、可行性分析、系统流程、架构设计、数据库实体与表设计、系统实现等章节,中英文摘要与目录齐备,既能作为论文写作框架蓝本,也可帮助理解网上书城从业务需求到技术落地的完整链路。压缩包内共1个docx文件,大小约5.04MB,结构清晰便于查阅与二次编辑。已有1427人学习下载,适合正在完成毕业设计、希望快速成稿并兼顾代码设计逻辑的同学参考。

1. 别把毕业设计做成“玩具”,从网上书城拆出 Spring Boot 的生产级细节

手里这份 Spring Boot 网上书城网站的 Java 毕业论文,表面上是常规的毕设选题,但把它逐页翻完后,我发现它的模块划分比大多数同类项目更接近真实商城:管理员端管用户、书籍分类、书籍信息、折扣书籍和订单,用户端有收藏、购物车和订单,前台还要兼顾书籍资讯展示。这意味着它不是简单 CRUD,而是涉及多角色权限、库存与订单状态联动、前后台数据隔离的完整闭环。如果你正在用 Spring Boot 写此类系统,或者准备拿它做面试项目,建议别停留在跑通流程,而是把自动装配原理、订单状态机、会话级购物车这些点吃透。下面我按这个项目的实际推进路线,从建表到订单回滚逐层拆解可复现的做法和踩坑点。

2. 数据库设计先行:从 E-R 实体关系到 MySQL 表结构的落地

2.1 实体关系梳理:先画清边界再写代码

网上书城项目最容易犯的错是一上来就写book表,等做到订单时再回来补字段。这份论文里的实体设计给出了一个合理的顺序:管理员、用户、书籍信息、折扣书籍、订单。我一般会把它们拆成五组关系:

  • 用户与订单:一对多,用户下单后产生多条订单记录。
  • 书籍与订单:多对多,但通过订单明细表解耦,避免订单表里出现冗余书籍字段。
  • 书籍与分类:多对一,分类表作为独立的字典表。
  • 折扣书籍与书籍:可以设计为关联同一张书籍表,通过discount_pricediscount_status字段区分,而不是单独建表。
  • 用户与收藏:多对多,需要一张中间表,字段包含用户 ID、书籍 ID、收藏时间。

这个项目的摘要里单独列出了“折扣书籍管理”,很多同学会为它专门建一张表。我的建议是:如果折扣只是价格字段和上下架时间的差异,就不要拆表,否则你后续做“折扣书籍列表”时还要联表查询,徒增复杂度。只有在折扣书籍有独立属性(比如限购数量、折扣规则)时才需要拆。

2.2 核心表结构设计:订单表必须包含冗余快照

先看用户表和书籍表,这里重点要说的是订单表。网上书城的下单逻辑里,用户下单后书籍价格可能调整,如果订单表只在关联字段里存一个book_id,那么用户查看历史订单时价格会变成最新价格,这在真实商城是不可接受的。所以订单表必须冗余书籍名称、书籍图片、下单时单价。

下面是精简后的建表语句,覆盖这个项目的核心业务:

CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录名', `password` varchar(100) NOT NULL COMMENT 'BCrypt加密后的密码', `nickname` varchar(50) DEFAULT NULL, `phone` varchar(11) DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; CREATE TABLE `book_category` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `category_name` varchar(50) NOT NULL, `sort_order` int(11) DEFAULT 0, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='书籍分类表'; CREATE TABLE `book_info` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `category_id` bigint(20) NOT NULL, `book_name` varchar(100) NOT NULL, `author` varchar(50) DEFAULT NULL, `publisher` varchar(100) DEFAULT NULL, `isbn` varchar(20) DEFAULT NULL, `price` decimal(10,2) NOT NULL, `discount_price` decimal(10,2) DEFAULT NULL, `discount_status` tinyint(1) DEFAULT 0 COMMENT '0-不是折扣书 1-折扣书', `stock` int(11) NOT NULL DEFAULT 0, `cover_image` varchar(255) DEFAULT NULL, `description` text, `sale_count` int(11) DEFAULT 0, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category_id` (`category_id`), KEY `idx_discount_status` (`discount_status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='书籍信息表'; CREATE TABLE `order_info` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '业务订单号,不直接用自增ID', `user_id` bigint(20) NOT NULL, `book_id` bigint(20) NOT NULL, `book_name` varchar(100) NOT NULL COMMENT '冗余快照', `book_image` varchar(255) DEFAULT NULL, `unit_price` decimal(10,2) NOT NULL COMMENT '下单时单价', `quantity` int(11) NOT NULL, `total_amount` decimal(10,2) NOT NULL, `status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '0-待支付 1-已支付 2-已发货 3-已完成 4-已取消', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `pay_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';

这里有几个容易被忽略的参数说明:

  • order_no不要用自增 ID 暴露给用户,我习惯用yyyyMMddHHmmss + 用户ID后四位 + 随机数生成 32 位以内的业务订单号,便于后续对接支付回调。
  • pricediscount_pricedecimal(10,2)而不是float,避免浮点精度问题。实际面试里追问“Java 中金额用什么类型”时,这也是标准答案。
  • book_info表把折扣信息冗余在普通书籍表里,通过discount_status区分是否折扣书,然后用idx_discount_status索引支撑前台“折扣书籍”栏目的快速查询。
  • utf8mb4 字符集是必须的,否则书籍简介里出现 emoji 符号会报Incorrect string value错误。

2.3 订单状态字段:用 tinyint 还是 varchar

论文里的订单管理涉及待支付、已支付、已发货、已完成、已取消等状态。很多入门项目用varchar(20)存中文状态,比如“已支付”,这样做也有好处:查询时一眼看懂。但缺点同样明显:状态变更逻辑里你需要写if ("已支付".equals(status)),一旦后台把“已支付”改成“已付款”,所有判断全部失效。

我在这个项目里用的是tinyint存数字,代码里用枚举统一映射:

public enum OrderStatus { PENDING_PAYMENT(0, "待支付"), PAID(1, "已支付"), SHIPPED(2, "已发货"), COMPLETED(3, "已完成"), CANCELLED(4, "已取消"); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code = code; this.desc = desc; } public static String getDescByCode(int code) { for (OrderStatus status : values()) { if (status.code == code) { return status.desc; } } return "未知状态"; } }

这样做的收益在管理端“订单管理”页面尤其明显:下拉框里的选项来自枚举,前端展示时用OrderStatus.getDescByCode(order.getStatus())转换,后端判断时用order.getStatus() == OrderStatus.PAID.getCode(),整个项目里不会出现魔法字符串。如果你准备面试,这个点可以展开讲,属于“Java 面试八股文”里枚举实战的加分项。

3. Spring Boot 后端实现:登录鉴权、书籍分页与下单事务

3.1 项目初始化与自动装配的取舍

论文里提到的开发环境是 Eclipse,但实际现在更多人用 IDEA 创建 Spring Boot 项目。注意 Spring Boot 版本选择上有个常见坑:不要一上来就选最新版,如果你的 JDK 是 8,就把spring-boot-starter-parent版本固定在 2.7.x,否则 3.x 强制要求 JDK 17,会导致大量环境配置问题。这也是“springboot版本太高”这个热搜词背后最常见的场景。

创建项目时,依赖建议这样选:

依赖坐标用途
spring-boot-starter-web提供 MVC 与嵌入式 Tomcat
spring-boot-starter-thymeleaf服务端页面渲染,替代前后端分离
spring-boot-starter-data-jpamybatis-spring-boot-starter数据访问层
mysql-connector-jMySQL 驱动
spring-boot-starter-validation参数校验
lombok简化实体类样板代码

这个网上书城用的是服务端渲染,所以我选择 Thymeleaf 而不是 Vue。论文里没有提前端框架,从“前台首页功能模块”和“后台管理”的描述看,它就是传统的浏览器交互模式,用 Thymeleaf 直接渲染页面最省事,也方便毕设答辩时讲清楚数据流转。

application.yml核心配置如下:

server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/bookstore?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456 jpa: hibernate: ddl-auto: update show-sql: true thymeleaf: cache: false prefix: classpath:/templates/ suffix: .html

参数说明:

  • serverTimezone=Asia/Shanghai不加的话,Java 8 与 MySQL 8 之间会有 8 小时时差,查到的时间比实际少 8 小时。
  • ddl-auto: update仅适合开发阶段,生产环境要改为validate或手动管理 SQL 脚本。如果你用这个项目去面试,面试官问你ddl-auto的几种取值,你需要能区分createupdatevalidatenone
  • Thymeleaf 的cache: false保证修改页面后刷新即可看到效果,不用重启应用。

3.2 登录鉴权:拦截器 + Session,不引入 Spring Security

网上书城分为管理员和用户两种角色,权限差异很明显。我的做法是写一个LoginInterceptor,根据 URL 前缀分流,而不是给每个 Controller 加重复判断。这样做的原因是 Spring Security 对这种简单角色区分而言过于笨重,而且在毕设答辩时容易陷入“你为什么不加权限注解”的追问。

核心代码:

@Component public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String uri = request.getRequestURI(); // 前台商品浏览和资讯不需要登录,但购物车和订单需要 if (uri.startsWith("/book") || uri.startsWith("/category") || uri.startsWith("/index") || uri.startsWith("/login") || uri.startsWith("/register")) { return true; } Object user = request.getSession().getAttribute("loginUser"); if (user == null) { // 管理员路径和管理后台路径区分对待 if (uri.startsWith("/admin") && request.getSession().getAttribute("loginAdmin") == null) { response.sendRedirect("/admin/login"); return false; } if (!uri.startsWith("/admin") && user == null) { response.sendRedirect("/login"); return false; } } return true; } }

这段代码的逻辑说明:

  • 前台首页、书籍列表、详情、登录注册接口直接放行。
  • 凡是/admin开头的请求,必须要求 Session 中存在loginAdmin,否则重定向到管理员登录页。
  • 普通用户访问购物车、下单、个人中心时,必须有loginUser
  • 拦截器注册时需要配置排除路径,否则静态资源会被误拦:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns("/**") .excludePathPatterns("/css/**", "/js/**", "/images/**", "/error"); } }

你会发现拦截器比注解更直接的地方在于:它天然覆盖整个模块,不需要担心有人漏加@RequireLogin注解。对网上书城这个项目来说,loginAdminloginUser两个 Session 键就足够区分权限了。

3.3 书籍分页查询:服务端分页的标准姿势

前台首页和“书籍信息”栏目都要展示书籍列表,这里不能一次性findAll然后前端用 JS 分页。数据量小的时候无所谓,但面试时被问“百万书籍怎么查”就会露怯。所以我用 Spring Data JPA 的分页接口实现:

public Page<BookInfo> searchBooks(Long categoryId, String keyword, Integer pageNum, Integer pageSize) { Pageable pageable = PageRequest.of(pageNum - 1, pageSize, Sort.by(Sort.Direction.DESC, "createTime")); if (categoryId != null) { return bookInfoRepository.findByCategoryIdAndBookNameContaining(categoryId, keyword, pageable); } return bookInfoRepository.findByBookNameContaining(keyword, pageable); }

这里有个参数细节:

  • PageRequest.of(pageNum - 1, pageSize)因为 JPA 的页码从 0 开始,而前端用户习惯从 1 开始。
  • 排序字段用createTime,新上架的书籍排在前面。
  • bookNameContaining会自动生成LIKE '%keyword%',但要注意 keyword 为空时不要拼出LIKE '%%',会全表扫描。可以在 Controller 层做空值处理。

对应的 Repository 方法:

public interface BookInfoRepository extends JpaRepository<BookInfo, Long> { Page<BookInfo> findByCategoryIdAndBookNameContaining(Long categoryId, String bookName, Pageable pageable); Page<BookInfo> findByBookNameContaining(String bookName, Pageable pageable); Page<BookInfo> findByDiscountStatus(Integer discountStatus, Pageable pageable); @Modifying @Query("update BookInfo b set b.stock = b.stock - :quantity where b.id = :bookId and b.stock >= :quantity") int deductStock(@Param("bookId") Long bookId, @Param("quantity") Integer quantity); }

deductStock这条方法很关键,它在数据库层完成库存扣减和乐观锁校验,避免并发下单时超卖。@Modifying必须配合@Transactional使用,否则会报TransactionRequiredException

3.4 下单事务:库存、订单、购物车必须同生共死

购物车在下单时清空,订单生成时扣库存,这两个操作只要有一个失败,就必须全部回滚。我用@Transactional把下单流程包起来:

@Transactional(rollbackFor = Exception.class) public OrderInfo createOrder(Long userId, Long bookId, Integer quantity) { BookInfo book = bookInfoRepository.findById(bookId).orElseThrow(() -> new RuntimeException("书籍不存在")); if (book.getStock() < quantity) { throw new RuntimeException("库存不足"); } String orderNo = generateOrderNo(userId); OrderInfo order = new OrderInfo(); order.setOrderNo(orderNo); order.setUserId(userId); order.setBookId(bookId); order.setBookName(book.getBookName()); order.setBookImage(book.getCoverImage()); // 折扣书籍取折扣价,否则取原价 order.setUnitPrice(book.getDiscountStatus() == 1 ? book.getDiscountPrice() : book.getPrice()); order.setQuantity(quantity); order.setTotalAmount(order.getUnitPrice().multiply(BigDecimal.valueOf(quantity))); order.setStatus(OrderStatus.PENDING_PAYMENT.getCode()); orderRepository.save(order); int updated = bookInfoRepository.deductStock(bookId, quantity); if (updated == 0) { throw new RuntimeException("库存扣减失败,可能已被抢购"); } return order; }

这段代码要说明的细节:

  • rollbackFor = Exception.class指定所有异常都回滚,因为 Spring 默认只对运行时异常回滚,检查异常不会回滚。
  • 库存扣减用UPDATE ... WHERE stock >= quantity这种条件更新,返回影响行数 0 就说明库存被并发更新。
  • 生成订单号时我用System.currentTimeMillis()加随机数,但如果是高并发场景必须加分布式 ID 组件,这里因为单机部署所以没问题。
  • 把书籍名称、单价冗余进订单,哪怕以后改书籍信息,旧订单依然能看到当时的快照。

4. 前台页面与购物车:Session 购物车与 Thymeleaf 渲染细节

4.1 购物车选型:为什么不用数据库表

项目里用户端有“购物车”功能,常见实现有两种方案。一种是给购物车建表,cart_id, user_id, book_id, quantity,这样做的好处是用户换设备购物车还在。坏处是每次操作都要走后端接口,而且未登录用户没法使用。这个网上书城项目的前台允许用户逛一逛再加入购物车,如果强制登录才能加购物车,转化率会很低。

所以我采用的方案是:未登录时购物车数据放在 Session 里,登录后点击结算把 Session 购物车转为数据库订单。这样既不用给购物车建表,也满足“先逛后买”的体验。

购物车的核心结构用HashMap<Long, Integer>就够了,key 是书籍 ID,value 是购买数量。封装一个CartService

public class CartService { public void addToCart(HttpSession session, Long bookId, Integer quantity) { Map<Long, Integer> cart = (Map<Long, Integer>) session.getAttribute("cart"); if (cart == null) { cart = new HashMap<>(); } cart.merge(bookId, quantity, Integer::sum); session.setAttribute("cart", cart); } public BigDecimal getCartTotal(Map<Long, Integer> cart, BookInfoRepository bookInfoRepository) { BigDecimal total = BigDecimal.ZERO; for (Map.Entry<Long, Integer> entry : cart.entrySet()) { BookInfo book = bookInfoRepository.findById(entry.getKey()).orElse(null); if (book != null) { BigDecimal price = book.getDiscountStatus() == 1 ? book.getDiscountPrice() : book.getPrice(); total = total.add(price.multiply(BigDecimal.valueOf(entry.getValue()))); } } return total; } }

这里有一个容易被忽略的问题:HttpSession中的购物车只适合单机部署。如果上线时用多台服务器做负载均衡,Session 会丢失,这时候必须换成 Redis 共享 Session。面试追问“购物车数据一致性”时,你可以把话题引到 Redis 上,然后给出spring-session-data-redis的整合方式。

4.2 Thymeleaf 页面渲染:列表页与详情页的表达式写法

前台首页需要展示书籍列表和折扣书籍模块,通过 Controller 传入Page<BookInfo>和分类列表,在 HTML 中循环渲染。

Controller 关键代码:

@Controller public class IndexController { @GetMapping("/index") public String index(@RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(required = false) Long categoryId, Model model) { Page<BookInfo> page = bookService.searchBooks(categoryId, null, pageNum, 8); model.addAttribute("page", page); model.addAttribute("categories", categoryService.findAll()); return "index"; } }

Thymeleaf 页面片段:

<div class="book-grid" th:each="book : ${page.content}"> <a th:href="@{'/book/detail/' + ${book.id}}"> <img th:src="${book.coverImage}" alt="封面"> </a> <h3 th:text="${book.bookName}">书名</h3> <p th:text="${book.author}">作者</p> <span class="price" th:text="${book.discountStatus == 1 ? book.discountPrice : book.price}"></span> <form th:action="@{/cart/add}" method="post"> <input type="hidden" name="bookId" th:value="${book.id}"> <input type="number" name="quantity" value="1" min="1"> <button type="submit">加入购物车</button> </form> </div>

这里要点名几个 Thymeleaf 的坑:

  • th:each遍历的是page.content,不是page对象本身,因为 Spring Data 的Page是个壳。
  • th:text里用三元表达式判断是否折扣书,注意价格字段如果是null,会输出空字符串,所以需要在实体类的getPrice()上做空值兜底。
  • 表单提交用th:action拼地址时,+前后要有空格,否则 Thymeleaf 直接报表达式解析错误。
  • 分页导航里,prev/next要用page.number + 1page.number - 1计算页码,并且要判断page.hasPrevious()再显示“上一页”,否则第一页时会出现一个空链接。

折扣书籍的展示逻辑与普通书籍基本一致,只是查询时加一个discountStatus = 1的条件。注意前台首页展示折扣书籍和普通书籍是两个独立栏目,不要混在一个th:each里,我建议用@GetMapping("/discount")单独做一个入口,方便后期运营调整推荐位。

4.3 书籍资讯与后台管理的联动

这个项目的前台还有“书籍资讯”模块,论文里系统管理下面有系统管理相关功能,我理解是公告或资讯管理。实际开发时,我建议资讯和书籍一样,建一张简单的article表,字段包含title, content, publish_time,管理员在后台发布,前台首页滚动展示最新三条。

由于 Session 中已经存放了用户信息,前台页面可以根据登录状态显示“个人中心”和“退出登录”,而非登录状态显示“登录/注册”。在 Thymeleaf 中:

<div th:if="${session.loginUser != null}"> <a th:href="@{/user/profile}">个人中心</a> <a th:href="@{/logout}">退出</a> </div> <div th:if="${session.loginUser == null}"> <a th:href="@{/login}">登录</a> <a th:href="@{/register}">注册</a> </div>

这里需要注意${session.loginUser}的写法,它映射的是 HttpSession 属性,不能写成${loginUser},除非你在 Model 里手动塞了用户对象。很多项目部署后页面报错,问题就出在这个细节上。

5. 折扣书籍定时上架与订单超时回滚的进阶细节

5.1 用@Scheduled实现折扣状态自动切换

网上书城的折扣书籍模块通常有“开始时间”和“结束时间”,运营希望时间一到自动上架或恢复原价。如果每次查询时通过now > startTime判断,逻辑散落在各个 Service 里,后期很难维护。我更推荐用 Spring Boot 自带的定时任务,每 30 秒扫描一次过期的折扣活动。

开启定时任务需要在启动类加@EnableScheduling,然后写一个定时组件:

@Component public class DiscountTask { private final BookInfoRepository bookInfoRepository; public DiscountTask(BookInfoRepository bookInfoRepository) { this.bookInfoRepository = bookInfoRepository; } @Scheduled(cron = "0 */1 * * * ?") @Transactional public void autoUpdateDiscountStatus() { // 找出所有折扣状态为1但结束时间已过的书籍 List<BookInfo> books = bookInfoRepository.findByDiscountStatusAndDiscountEndTimeBefore(1, new Date()); for (BookInfo book : books) { book.setDiscountStatus(0); book.setDiscountPrice(null); } if (!books.isEmpty()) { bookInfoRepository.saveAll(books); System.out.println("定时任务:已回滚 " + books.size() + " 本过期折扣书籍"); } } }

参数说明:

  • cron表达式0 */1 * * * ?表示每分钟的第 0 秒执行一次。如果你希望整点执行,用0 0 * * * ?
  • 注意文章里的定时任务必须加@Transactional,因为saveAll是批量操作,中途失败时保证已更新的部分回滚。
  • 查询条件findByDiscountStatusAndDiscountEndTimeBefore会自动生成WHERE discount_status = 1 AND discount_end_time < ?的 SQL,由 Spring Data JPA 方法名推导,不需要写 JPQL。
  • 定时任务里的日志不要用System.out,生产环境应该用LoggerFactory.getLogger(...)

这个方法比数据库事件更可控,因为运营调整折扣时间后,任务会按新时间执行。还有一个细节:如果折扣书籍在首页有缓存,定时任务改完数据库后必须清理缓存,否则用户看到的价格还是旧的。如果项目里没有引入 Redis,至少要在定时任务里调用一次CacheManagerclear()方法。

5.2 订单超时未支付自动取消

这个功能在毕设论文里未必会写,但面试官十有八九会问“下单后一直不支付怎么办”。网上书城的订单表有status=0待支付和create_time,最简单的方案就是再写一个定时任务,把创建时间超过 30 分钟的待支付订单置为取消状态。

代码如下:

@Component public class OrderTimeoutTask { private final OrderRepository orderRepository; @Scheduled(fixedDelay = 60000, initialDelay = 10000) @Transactional public void cancelExpiredOrders() { Date timeout = new Date(System.currentTimeMillis() - 30 * 60 * 1000); List<OrderInfo> expiredOrders = orderRepository.findByStatusAndCreateTimeBefore(OrderStatus.PENDING_PAYMENT.getCode(), timeout); for (OrderInfo order : expiredOrders) { order.setStatus(OrderStatus.CANCELLED.getCode()); // 回补库存:因为下单时已经扣减了库存 bookInfoRepository.increaseStock(order.getBookId(), order.getQuantity()); } orderRepository.saveAll(expiredOrders); } }

这里有两个值得展开的踩坑点:

  1. 必须回补库存。下单事务里已经扣了库存,订单取消后如果不加回去,库存会越卖越少,最后变成“永远缺货”。所以我在BookInfoRepository里补充了一个@Modifying方法increaseStock,对应UPDATE book_info SET stock = stock + ? WHERE id = ?
  2. 仅靠定时任务不足以应对极端情况。如果服务正好在订单超时时宕机,任务不会执行,订单就一直挂着。更可靠的做法是在用户查询订单时做“懒取消”,也就是每次查询到待支付订单时,额外检查是否超时,超时则顺带更新状态。你可以把两个方法组合起来。

5.3 验证与自测建议

写完上述功能后,我建议按下面顺序验证:

  1. 启动项目,注册两个账号,一个普通用户,一个管理员(直接在user表插入一条role=1的管理员记录,因为论文里的需求分析没有提供注册管理员的入口)。
  2. 管理员登录后台,新增书籍分类、书籍信息,并设置折扣状态和折扣价。
  3. 前台搜索书籍,加入购物车,查看购物车总价是否同时包含原价书和折扣书。
  4. 下单后不要支付,到数据库执行UPDATE order_info SET create_time = DATE_SUB(NOW(), INTERVAL 40 MINUTE) WHERE status = 0,手动把时间改到 40 分钟前,等待下一分钟定时任务运行。
  5. 检查订单状态是否变为已取消,对应书籍库存是否回到下单前的值。
  6. 再测试并发下单:用 JMeter 或写一个简单的for循环模拟 50 个线程同时购买同一本书,观察库存扣减是否正确,订单表是否有重复数据。

这个验证流程把代码和数据库联动起来,比单纯跑通界面更能说明你理解了这个系统的边界。如果最后做答辩展示,建议把第 5 步的 SQL 执行过程录成小视频,面试官看到你能主动构造超时场景,会认为你不是只会照搬教程。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/20 7:02:00

Windows下使用nvm管理Node.js多版本:安装配置与实战排错指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 7:01:33

OpenResearch:构建可复现的科研协作工作流

1. 为什么"OpenResearch"值得单独拿出来聊第一次看到"OpenResearch"这个词&#xff0c;是在一个做科研工具的朋友群里。有人甩了张截图&#xff0c;说他们实验室最近在折腾一套叫 OpenResearch 的东西&#xff0c;把组里散落在各个硬盘、聊天记录、邮件附件…

作者头像 李华
网站建设 2026/9/20 7:01:13

Vue2与Vue3响应式系统核心原理与性能对比

1. 响应式系统基础概念解析前端开发中&#xff0c;响应式系统是现代框架的核心竞争力。简单来说&#xff0c;响应式就是当数据变化时&#xff0c;视图自动更新的机制。想象你正在玩一个遥控汽车&#xff0c;转动方向盘&#xff08;数据变化&#xff09;时&#xff0c;车轮方向&…

作者头像 李华
网站建设 2026/9/20 7:00:55

用Git Worktree为AI Agent并行开发打造独立工作区

1. 为什么我给每个 AI Agent 单独开了一个工作区先讲一个真实的场景。上个月我同时推进三件事&#xff1a;用 codex CLI 改一个接口的鉴权逻辑&#xff0c;用 Claude Code 调前端页面的样式问题&#xff0c;还给另一个 Agent 派了修测试失败的任务。三个 LLM 驱动的 Agent 同时…

作者头像 李华
网站建设 2026/9/20 6:58:28

自托管LibreChat部署指南:统一管理多模型AI对话

1. 为什么我最终选择了自托管LibreChat1.1 从“多平台来回切换”到“一个入口搞定”我日常要处理的事情很杂&#xff1a;写技术方案、查资料、翻译文档、整理会议纪要、偶尔还要跑几段代码验证逻辑。过去半年&#xff0c;我的浏览器里常年开着四五个AI对话标签页&#xff0c;每…

作者头像 李华