资讯详情

资讯详情

Spring Boot三角色商城系统课程设计:JPA、事务与避坑

简介面向计算机及相关专业本科生与研究生的课程设计参考文档围绕 Spring Boot 框架下的网上购物商城系统展开覆盖需求分析、总体与详细设计、数据库建模到系统测试的完整开发链路。文档按用户、商家、管理员三类角色梳理功能模块涉及注册登录、商品浏览与购买、商家商品维护、后台用户与商家管理等场景并给出 E-R 图与数据表结构说明测试部分从功能、性能、安全三个维度展开分析结果并指出改进方向。资源包仅含 1 个 docx 文件约 3.8MB即课程设计报告正文含中英文摘要、目录、开发技术介绍Java、Spring Boot、B/S 结构、MySQL与用例分析可直接作为写作模板与项目案例对照。已有 76 人学习下载适合需要一份完整电商系统设计范例、想理清 Spring Boot 项目文档结构、补充测试章节写法或准备答辩的读者参考。1. 拿到一份课程设计三角色商城系统能直接跑起来吗某同学把课程设计压缩包解压IDEA 里点一下运行控制台刷出Tomcat started on port 8080心说成了结果浏览器打开登录页输账号密码点登录页面直接白屏控制台一条 SQL 报错都没打印。这类基于 Spring Boot 的网上购物商城系统在课程设计里太常见了——Java Spring Boot MySQLB/S 结构用户、商家、管理员三个角色各自一套菜单商品浏览、加入购物车、下单、后台审核、订单管理一条龙。它适合两类人一是被课程设计卡住、需要一个功能闭环完整、设计文档齐全的参考实现二是刚学完 Spring Boot 想找个 CRUD 项目练手、把三层结构、JPA 映射和事务边界摸一遍的开发者。功能不复杂但麻雀虽小该翻车的地方一个不少下面从结构到代码一层层拆。2. 技术栈与目录结构Spring Boot JPA 的三层骨架长什么样2.1 为什么这套系统选 Spring Boot 加 Spring Data JPA而不是传统 SSM课程设计的时间窗口通常只有几周技术选型的第一原则是「少写配置、快出页面」。传统 SSM 要手写web.xml、注册DispatcherServlet、配spring-mvc.xml、mybatis-config.xml光是把 XML 调通就能耗掉两三天Spring Boot 把这些全部收进自动配置里一个SpringBootApplication启动类再加一份application.yml就能跑内嵌 Tomcat 也省掉了单独装容器的步骤。数据访问层选 Spring Data JPA 而不是 MyBatis理由更直接这套系统的查询九成是「按条件查列表 按主键查详情 保存/删除」findById、save、deleteById、findAll(Pageable)这几个方法覆盖掉大部分场景接口继承一下JpaRepository就有实现不用为每张表写一份 Mapper XML。代价也要说清楚。JPA 在复杂联表统计上不如手写 SQL 顺手比如「按分类统计近三十天销量」这种最后还是要落到Query上写原生 SQL。另外 JPA 的ddl-auto一旦开着update实体改一个字段名它就会去动表结构课程设计阶段图省事可以开着真要交付给别人跑就得关掉改成手动执行脚本这一点在第 5 章会细讲。还有一个容易被忽略的点Spring Boot 内嵌 Tomcat 对 JSP 的支持很差打成一个可执行 jar 之后 JSP 基本跑不起来所以落地实现基本都是 Thymeleaf 做服务端渲染Controller 返回视图名模板放在templates目录下。原文里提到的 B/S 结构在代码里体现的就是这条路浏览器发请求服务端渲染页面回写。!-- pom.xml 关键依赖parent 版本照抄压缩包里的 pom别自己往 3.x 上跳 -- parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId !-- 以 2.7.x 为例升级大版本会连带 javax/jakarta 包名一起变 -- version2.7.6/version /parent dependencies !-- Web 起点内含内嵌 TomcatB/S 结构的服务端入口 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- 模板引擎负责把后端数据渲染成页面上那些商品卡片和表格 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency !-- Spring Data JPA省掉每张表一份 Mapper XML -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency !-- MySQL 驱动运行时才用scope 写 runtime -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency !-- 参数校验注册登录表单靠它兜底 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency /dependencies这段依赖清单里真正会出问题的只有两处。一是spring-boot-starter-parent的版本很多同学看到网上教程写 3.x 就跟着改结果项目里全是javax.persistence.*的 import升级后包名变成jakarta.persistence.*一编译满屏红。二是 MySQL 驱动的坐标老项目里写的是mysql:mysql-connector-java新版本改成了com.mysql:mysql-connector-j两者不能混着写混了就是启动时ClassNotFoundException。判断标准很简单pom.xml里写什么就用什么别改。2.2 目录结构逐层对照一次请求从 Controller 落到 Repository这套系统的 Java 包结构是标准的按层分包不是按功能分包。按层分包在中小项目里反而更好找东西——想改商品查询逻辑直接去repository找GoodsRepository不用在五个 feature 包里翻。shopping-mall ├── src/main/java/com/example/mall │ ├── MallApplication.java // 启动类SpringBootApplication │ ├── config/ │ │ └── WebMvcConfig.java // 注册拦截器、配置静态资源映射 │ ├── interceptor/ │ │ └── LoginInterceptor.java // 三角色登录态校验 │ ├── controller/ │ │ ├── IndexController.java // 首页、商品列表、详情 │ │ ├── CartController.java // 购物车增删改查 │ │ ├── OrderController.java // 下单、订单列表、取消 │ │ ├── UserController.java // 注册、登录、个人中心 │ │ ├── MerchantController.java // 商家后台商品发布、订单发货 │ │ └── AdminController.java // 管理后台用户、商家、分类、审核 │ ├── service/ │ │ ├── GoodsService.java │ │ ├── OrderService.java │ │ └── UserService.java │ ├── repository/ │ │ ├── GoodsRepository.java // 继承 JpaRepository JpaSpecificationExecutor │ │ └── OrderRepository.java │ ├── entity/ │ │ ├── User.java Merchant.java Goods.java │ │ ├── Category.java Cart.java Order.java │ └── common/ │ ├── Result.java // 统一返回体 │ └── PageResult.java // 分页包装 ├── src/main/resources │ ├── application.yml │ ├── static/ // css、js、商品占位图 │ ├── templates/ // Thymeleaf 页面按 front/merchant/admin 分目录 │ └── db/init.sql // 建表 初始化数据 └── pom.xml对照这份结构一次「点进商品详情」的调用链是这样的浏览器请求/goods/detail/12IndexController接住路径变量调GoodsService.findById(12)Service 层做业务判断是否上架、是否审核通过再调GoodsRepository.findById(12)JPA 自动生成select * from goods where id ?结果回填到Goods实体Controller 把它塞进Model返回front/goods-detailThymeleaf 在templates/front/goods-detail.html里把字段渲染出来。整条链路没有一处需要手写 SQL这也是选 JPA 的直接收益。按功能分包的支持者会说这样controller目录会堆十几个类确实如此。但课程设计这个体量十几个 Controller 排在同一个目录里反而一目了然找入口比找归属快。2.3 application.yml 里的三个开关决定你能不能一次启动成功配置文件是新手最容易卡住的地方。下面这份是能跑起来的最小配置每一项都对应一个具体的失败现象。server: port: 8080 servlet: session: timeout: 30m # 后台管理页面填一半超时就是这个值太短 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver # useUnicode characterEncoding 必须带不然中文商品名入库变问号 url: jdbc:mysql://localhost:3306/shopping_mall?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: root jpa: hibernate: ddl-auto: update # 开发期用 update交付前改成 none 并手动执行 init.sql show-sql: true # 调试期打开能直接看到 JPA 生成的 SQL properties: hibernate: format_sql: true thymeleaf: cache: false # 改完 html 不重启就能看到效果 prefix: classpath:/templates/ suffix: .htmlddl-auto: update的作用是启动时对比实体和表结构缺表建表、缺字段加字段。开发阶段它是效率神器但也有副作用它只会加不会删你删掉一个实体字段数据库里那列还在而且如果实体字段类型改了它可能直接建一个新列而不是改原列。交付给别人跑之前一定改成none让对方先执行db/init.sql。serverTimezone这个参数看着不起眼缺了它 MySQL 8 会抛The server time zone value ... is unrecognized启动直接失败。characterEncodingutf8缺了不会报错但商品名、收货地址里的中文会变成乱码这个错误更隐蔽——程序照常跑数据就是不对。thymeleaf.cache: false只影响开发体验线上要改回true否则每次请求都重新解析模板。3. 三角色权限与核心链路注册登录、购物车、下单的代码怎么走3.1 登录拦截与角色分流一个 HandlerInterceptor 管三种身份这套系统的权限模型很朴素用户、商家、管理员三张表三个登录入口登录成功后往 Session 里写role和loginId后续请求靠拦截器判断。没有引入 Spring Security原因是课程设计阶段配 Security 的WebSecurityConfigurerAdapter反而会增加理解成本而拦截器五十行代码就能说清楚。Component public class LoginInterceptor implements HandlerInterceptor { // 白名单前缀登录页、注册页、静态资源、错误页不拦 private static final ListString WHITE_LIST Arrays.asList( /login, /register, /css, /js, /img, /error, /goods ); Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String uri request.getRequestURI(); // 先过白名单否则登录页自己都会被重定向到登录页死循环 for (String white : WHITE_LIST) { if (uri.startsWith(white)) { return true; } } HttpSession session request.getSession(); Object role session.getAttribute(role); if (role null) { response.sendRedirect(/login); return false; } // 按路径前缀做二次校验防止用户手动敲 /admin/goods 越权 if (uri.startsWith(/admin) !ADMIN.equals(role)) { response.sendRedirect(/login); return false; } if (uri.startsWith(/merchant) !MERCHANT.equals(role)) { response.sendRedirect(/login); return false; } return true; } }代码里的关键是白名单的判断顺序。很多人把 Session 判断写在白名单前面结果访问/login时 Session 里没有role被重定向回/login浏览器提示「重定向次数过多」。另一个容易漏的是静态资源/css、/js不放过的话登录页样式全丢页面裸奔看起来像「前端坏了」实际是拦截器把样式文件一起拦了。拦截器写完还得注册否则它根本不生效Configuration public class WebMvcConfig implements WebMvcConfigurer { Resource private LoginInterceptor loginInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns(/**) // 先全拦 .excludePathPatterns( // 再排除登录注册和静态资源 /login, /register, /css/**, /js/**, /img/** ); } Override public void addResourceHandlers(ResourceHandlerRegistry registry) { // 商品图片如果放在项目外的目录需要额外映射否则 img 标签 404 registry.addResourceHandler(/upload/**) .addResourceLocations(file: System.getProperty(user.dir) /upload/); } }addPathPatterns(/**)加excludePathPatterns的组合等于在配置层面又做了一次白名单和拦截器内部的判断形成了双保险。两处白名单不一致是常见 bug配置里排除了/goods拦截器内部没排除访问商品详情就跳登录。我一般建议只在拦截器内部维护一份白名单配置里写/**全拦逻辑集中在一处。3.2 商品列表的分页与条件搜索用 Specification 拼动态查询首页的商品列表要支持「分类筛选 关键词搜索 分页 只显示已上架且审核通过的」。这四个条件里分类和关键词是可选的用 JPA 的Specification拼动态条件最合适——不写 SQL、不拼字符串、也不会有 SQL 注入风险。Service public class GoodsService { Resource private GoodsRepository goodsRepository; public PageGoods search(String keyword, Long categoryId, int page, int size) { SpecificationGoods spec (root, query, cb) - { ListPredicate predicates new ArrayList(); // 前台只展示 status1已上架的商品 predicates.add(cb.equal(root.get(status), 1)); // 关键词非空才拼 like注意前后都要拼百分号 if (StringUtils.hasText(keyword)) { predicates.add(cb.like(root.get(name), % keyword.trim() %)); } // categoryId 为 null 或 0 表示全部分类不加条件 if (categoryId ! null categoryId 0) { predicates.add(cb.equal(root.get(categoryId), categoryId)); } return cb.and(predicates.toArray(new Predicate[0])); }; // JPA 的页码从 0 开始前端传的是第 1 页这里必须减 1 int pageIndex Math.max(page - 1, 0); Pageable pageable PageRequest.of(pageIndex, size, Sort.by(Sort.Direction.DESC, id)); return goodsRepository.findAll(spec, pageable); } }参数说明keyword来自搜索框categoryId来自左侧分类栏page是前端传来的页码从 1 开始size是每页条数。Sort.by(Sort.Direction.DESC, id)保证新发布的商品排前面。返回值PageGoods里除了数据列表还带了totalElements、totalPages前端分页条直接用这两个值渲染。分页这里有个经典翻车点PageRequest.of的页码从 0 开始前端从 1 开始如果直接把page塞进去第 1 页拿到的其实是第 2 页的数据第 1 条永远看不到。我习惯在 Service 层统一减 1Controller 层不做任何转换这样前端不用记这个规矩。GoodsRepository需要同时继承两个接口才支持这种查询public interface GoodsRepository extends JpaRepositoryGoods, Long, JpaSpecificationExecutorGoods { // 扣库存用带条件的 update比先查再改抗并发 Modifying Query(update Goods g set g.stock g.stock - :num where g.id :id and g.stock :num) int reduceStock(Param(id) Long id, Param(num) Integer num); }Modifying告诉 JPA 这是一条更新语句而不是查询缺了它启动就报Expecting a SELECT query。where ... and g.stock :num这个条件是防超卖的关键它把「库存够不够」的判断下推到了数据库返回值为 0 就说明扣减失败。3.3 从购物车到订单Transactional 里那几百毫秒决定数据对不对下单是整个系统里唯一需要严格事务的地方——扣库存、写订单、清购物车三步要么全成要么全滚。这段代码写错就会出「库存扣了订单没生成」或者「订单生成了库存还是原样」的玄学问题。Service public class OrderService { Resource private GoodsRepository goodsRepository; Resource private OrderRepository orderRepository; Resource private CartRepository cartRepository; Transactional(rollbackFor Exception.class) public String submit(Long userId, Long goodsId, Integer num, String address, Integer payType) { // 1. 查商品不存在直接抛事务回滚 Goods goods goodsRepository.findById(goodsId) .orElseThrow(() - new RuntimeException(商品不存在)); // 2. 库存预判给用户一个明确提示真正的防超卖靠下面的 update if (goods.getStock() null || goods.getStock() num) { throw new RuntimeException(库存不足); } // 3. 带条件扣库存返回 0 说明并发下被别人抢先了 int rows goodsRepository.reduceStock(goodsId, num); if (rows 0) { throw new RuntimeException(手慢了商品已被抢完); } // 4. 生成订单快照商品名、价格都冗余存一份商品改价不影响历史订单 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setGoodsId(goodsId); order.setGoodsName(goods.getName()); order.setGoodsImg(goods.getImage()); order.setNum(num); order.setPrice(goods.getPrice()); order.setTotal(goods.getPrice().multiply(new BigDecimal(num))); order.setAddress(address); order.setPayType(payType); order.setStatus(0); // 0 待付款 1 已付款 2 已发货 3 已完成 4 已取消 order.setCreateTime(new Date()); orderRepository.save(order); // 5. 清掉这条购物车记录避免用户刷新页面重复下单 cartRepository.deleteByUserIdAndGoodsId(userId, goodsId); return order.getOrderNo(); } private String generateOrderNo() { // 时间戳 三位随机数课程设计够用高并发场景要换雪花算法 return System.currentTimeMillis() String.format(%03d, new Random().nextInt(1000)); } }几个参数必须解释清楚。rollbackFor Exception.class不能省Spring 默认只对RuntimeException和Error回滚如果代码里抛的是受检异常事务不会回滚库存扣了订单没写数据就歪了。status字段的取值要在项目里统一约定0 到 4 这套是常见做法前后端、数据库注释三处必须一致否则改一处忘一处列表页就显示成空白。order.setPrice(goods.getPrice())和setGoodsName这两行是「订单快照」。商家过两天把商品降价历史订单里显示的仍然是下单时的价格这是电商系统的硬要求。把这两个字段直接冗余在订单表里比每次查订单再去关联商品表要稳商品下架了订单也不会查不出来。4. 数据库设计落地E-R 图对应到建表语句与实体类4.1 六张核心表与字段取舍原文的 E-R 图里画了商品分类、商家、订单、管理员、商品五个实体落到实际建表还要加上用户、购物车、收藏一共八张表。这里挑最核心的六张把字段和取舍说清楚。表名作用关键字段设计取舍user前台买家id、username、password、phone、address地址直接存一列字符串不做地址簿表merchant商家id、account、password、name、balance、statusbalance 用 decimal(10,2)不用 doublecategory商品分类id、name、sort不做多级树一级分类够用goods商品id、merchant_id、category_id、name、price、stock、statuscategory_id 存外键值不建物理外键约束cart购物车id、user_id、goods_id、num不做「选中状态」列勾选状态在前端维护orders订单id、order_no、user_id、goods_id、num、price、total、status一单一商品没有订单明细表需要点明的是goods表的外键策略。物理外键FOREIGN KEY在课程设计里加了看起来很规范但实际开发中删分类、删商家都会被外键挡住报Cannot delete or update a parent row。常见做法是只存外键值关联关系在应用层维护删除时做逻辑删status置 0。金额字段统一用decimal(10,2)不用float也不用double。浮点数做金额计算会出现0.1 0.2 0.30000000000000004这种经典问题商品单价乘数量之后总价对不上用户一眼就看出不对。Java 侧对应BigDecimal两边配套。4.2 实体映射ManyToOne 还是存冗余字段实体类和表之间怎么映射是 JPA 项目里最容易分歧的地方。两种写法一种用ManyToOne做对象关联一种只存 id 值。这套系统在goods上用的是后者。Entity Table(name goods) public class Goods { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; // 只存商家 id不写 ManyToOne避免查商品时连带查出商家对象 Column(name merchant_id) private Long merchantId; Column(name category_id) private Long categoryId; Column(nullable false, length 100) private String name; // 金额统一 BigDecimalprecision 对应 decimal(10,2) Column(precision 10, scale 2) private BigDecimal price; private Integer stock; private Integer sales; Column(length 255) private String image; // 详情用 TEXTLob 在 MySQL 下容易映射成 longtext可以直接指定 columnDefinition Column(columnDefinition TEXT) private String detail; // 1 已上架 0 已下架 private Integer status; Column(name create_time) private Date createTime; }为什么不用ManyToOne因为这套系统的列表页只需要商品自己的字段商家名和分类名在详情页才需要用 id 值单独查一次反而更可控。ManyToOne默认是FetchType.EAGER查一页商品会连带把每个商家对象都查出来二十条商品二十次额外查询接口响应肉眼可见地变慢。就算改成LAZYThymeleaf 渲染时触发懒加载又可能抛LazyInitializationException得再配OpenEntityManagerInView链条越拉越长。存 id 值的代价是拿到商品之后要再查一次商家名。这个代价在课程设计体量下可以忽略而且完全可以接受——详情页多一次merchantRepository.findById()代码直白调试时 SQL 日志清清楚楚比在懒加载和事务边界的黑匣子里猜要强。主键生成策略用GenerationType.IDENTITY对应 MySQL 的AUTO_INCREMENT。用AUTO在某些数据库下会走 sequenceMySQL 里表现不一致明确写IDENTITY更稳。4.3 建表脚本与初始化数据交付前一定要自己跑一遍ddl-auto: update生成的表结构不规范字段顺序、索引、注释都不可控。正式交付时手写一份init.sql放在src/main/resources/db/下让别人导入就能跑。-- 建库语句单独一行导入前先执行 CREATE DATABASE IF NOT EXISTS shopping_mall DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE shopping_mall; DROP TABLE IF EXISTS orders; CREATE TABLE orders ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 订单号时间戳随机数, user_id BIGINT NOT NULL COMMENT 下单用户, goods_id BIGINT NOT NULL COMMENT 商品 id, goods_name VARCHAR(100) NOT NULL COMMENT 下单时的商品名快照, goods_img VARCHAR(255) DEFAULT NULL, num INT NOT NULL DEFAULT 1, price DECIMAL(10,2) NOT NULL COMMENT 下单时单价, total DECIMAL(10,2) NOT NULL COMMENT 应付总额, address VARCHAR(255) NOT NULL, pay_type TINYINT DEFAULT 1 COMMENT 1 在线 2 货到付款, 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_id (user_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表; -- 初始化管理员账号密码 123456 的 MD5方便第一次登录 INSERT INTO admin (username, password, create_time) VALUES (admin, e10adc3949ba59abbe56e057f20f883e, NOW());utf8mb4而不是utf8是因为 MySQL 的utf8实际只支持三字节字符商品名里出现一个 emoji 就存不进去报Incorrect string value。order_no上加了唯一索引防的是时间戳随机数撞车导致的重复订单号虽然概率极低但加了索引至少数据库层面会挡住。idx_user_id和idx_status两个索引对应两个高频查询「我的订单」按user_id查「待发货订单」按status查。课程设计数据量小索引效果看不出来但建表时顺手加上不亏也顺便把「索引该建在哪些列」练一遍。5. 避坑排查五个让系统「看起来能跑但数据不对」的现场这套系统启动成功不代表功能正确。下面五条是我在不同项目里反复见到的坑每条按现象、原因、解决写遇到时照着排查。5.1 环境与配置类问题登录页样式丢失、中文变问号现象一登录页打开是一片纯文本CSS 完全没生效。原因大概率是LoginInterceptor把静态资源一起拦了/css/login.css被重定向到/login浏览器拿回一段 HTMLCSS 自然加载失败。排查方法很简单打开浏览器开发者工具的 Network 面板看 css 请求的状态码如果是 302 就是被拦了。解决是在WebMvcConfig的excludePathPatterns里加上/css/**、/js/**、/img/**或者直接在拦截器内部的白名单里补上。两处都改也行但建议只留一处避免以后改一漏一。现象二商品名、收货地址入库后变成一堆问号或者中文乱码成中文。三个地方要同时对上数据库表的字符集要是utf8mb4JDBC URL 里要带characterEncodingutf8IDEA 的文件编码要是 UTF-8。三处缺任何一处都会乱码逐项核对。还有一种情况是数据库建了但表没建好ddl-auto按默认字符集建表这时执行SHOW CREATE TABLE goods看表的字符集就能确认。5.2 业务逻辑类问题多商品下单只生成一单、第一页数据看不到现象三购物车里勾了三种商品一起结算结果订单列表只显示一件。这不是 bug是表设计的限制——orders表直接存了goods_id一张订单只能对应一件商品。原文 E-R 图里的订单实体也是这个结构所以是设计层面的简化。解决要分两步先加order_item表存商品明细字段包括order_id、goods_id、goods_name、price、num、total再把OrderService.submit从「单个商品」改成「接收购物车 id 列表」循环扣库存、循环写明细主表只存订单号和总额。改造时事务范围要包住整个循环库存扣到一半失败必须全部回滚。现象四商品列表翻页第 1 页的数据永远看不到直接从第 2 页开始。原因是PageRequest.of(page, size)的页码从 0 开始前端传的是 1。这个坑踩一次能记一辈子因为现象太迷惑——总条数对页数对就是内容错位。解决是在 Service 层统一Math.max(page - 1, 0)或者干脆约定前端从 0 开始传。两种做法都行但一定要在项目里统一别有的接口减有的不减那排查起来就是血泪经验。现象五改了密码之后老账号登不进来新注册的账号正常。这是加密策略中途变更导致的。很多项目一开始密码明文存储后来改成 MD5登录逻辑改成「把输入的密码 MD5 之后比对」但数据库里的老数据还是明文自然对不上。解决要么写一个迁移脚本把存量密码批量 MD5要么在登录时做兼容判断比对不成功时再拿输入值跟库里的值直接比一次成功了就顺手升级成 MD5。后者是常见做法能平滑过渡但要记得过渡期结束后把兼容分支删掉。6. 二次开发改造从「能交作业」到「能放进作品集」的三步走第一件该做的事是补上订单明细表这是这套系统距离真实电商最近的一步。加表不难难的是把原有逻辑改干净OrderService里所有用到Order.getGoodsId()的地方都要改成遍历明细订单列表页的渲染也要从「一条订单一个商品」改成「一条订单 N 个商品 展开明细」。改完之后用一张回归清单验证单商品下单、多商品下单、下单后取消、取消后库存回滚四条都跑通才算完成。取消订单时库存回滚别忘了Transactional里补一次goodsRepository.addStock(goodsId, num)。第二步是加优惠券。最小实现只需要两张表coupon面额、门槛、有效期、库存和user_coupon领取记录、使用状态。下单时多一个couponId参数OrderService里加三段判断——券是否存在、是否本人领取、订单金额是否达到门槛通过后total减去券面额同时把user_coupon.status置为已使用。三段判断必须都在事务内否则并发下同一张券能被用两次。第三步最有价值把 Thymeleaf 换成前后端分离。做法是给RestController单独加一组/api/**路径返回统一Result包装的 JSON原有 Controller 保留不动两套并行跑一段时间页面一个模块一个模块替换。判断接口是否可用的方法很土但很有效# 先登录拿 Cookie再把 Cookie 带进业务接口验证 curl -i -c cookie.txt -X POST http://localhost:8080/api/login \ -d usernametestpassword123456 curl -b cookie.txt http://localhost:8080/api/order/list?page1size10第一条命令里的-c cookie.txt把 Session 写到文件第二条-b cookie.txt带回去能打通说明拦截器和 Session 都没问题。这套流程我一般会写成脚本每改完一个接口就跑一遍比在浏览器里点快得多。最后留一个习惯每次交付前我都强制走一遍「删库 → 执行 init.sql → 全新启动 → 注册新号 → 下单 → 后台审核 → 发货」这条完整链路中间任何一步卡住都不算完成。自己机器上跑得通不叫通从零开始的机器上跑得通才叫通。这套系统结构不复杂但正因为不复杂才更容易在小地方翻车。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →