简介:面向计算机及相关专业本科生与研究生的课程设计参考文档,围绕 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 + MySQL,B/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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <!-- 以 2.7.x 为例,升级大版本会连带 javax/jakarta 包名一起变 --> <version>2.7.6</version> </parent> <dependencies> <!-- Web 起点,内含内嵌 Tomcat,B/S 结构的服务端入口 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 模板引擎,负责把后端数据渲染成页面上那些商品卡片和表格 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!-- Spring Data JPA,省掉每张表一份 Mapper XML --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <!-- MySQL 驱动,运行时才用,scope 写 runtime --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- 参数校验,注册登录表单靠它兜底 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-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/12,IndexController接住路径变量,调GoodsService.findById(12),Service 层做业务判断(是否上架、是否审核通过),再调GoodsRepository.findById(12),JPA 自动生成select * from goods where id = ?,结果回填到Goods实体,Controller 把它塞进Model,返回"front/goods-detail",Thymeleaf 在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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false 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,启动直接失败。characterEncoding=utf8缺了不会报错,但商品名、收货地址里的中文会变成乱码,这个错误更隐蔽——程序照常跑,数据就是不对。
thymeleaf.cache: false只影响开发体验,线上要改回true,否则每次请求都重新解析模板。
3. 三角色权限与核心链路:注册登录、购物车、下单的代码怎么走
3.1 登录拦截与角色分流:一个 HandlerInterceptor 管三种身份
这套系统的权限模型很朴素:用户、商家、管理员三张表,三个登录入口,登录成功后往 Session 里写role和loginId,后续请求靠拦截器判断。没有引入 Spring Security,原因是课程设计阶段配 Security 的WebSecurityConfigurerAdapter反而会增加理解成本,而拦截器五十行代码就能说清楚。
@Component public class LoginInterceptor implements HandlerInterceptor { // 白名单前缀:登录页、注册页、静态资源、错误页不拦 private static final List<String> 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 Page<Goods> search(String keyword, Long categoryId, int page, int size) { Specification<Goods> spec = (root, query, cb) -> { List<Predicate> predicates = new ArrayList<>(); // 前台只展示 status=1(已上架)的商品 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")保证新发布的商品排前面。返回值Page<Goods>里除了数据列表,还带了totalElements、totalPages,前端分页条直接用这两个值渲染。
分页这里有个经典翻车点:PageRequest.of的页码从 0 开始,前端从 1 开始,如果直接把page塞进去,第 1 页拿到的其实是第 2 页的数据,第 1 条永远看不到。我习惯在 Service 层统一减 1,Controller 层不做任何转换,这样前端不用记这个规矩。
GoodsRepository需要同时继承两个接口才支持这种查询:
public interface GoodsRepository extends JpaRepository<Goods, Long>, JpaSpecificationExecutor<Goods> { // 扣库存:用带条件的 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、status | balance 用 decimal(10,2),不用 double |
category | 商品分类 | id、name、sort | 不做多级树,一级分类够用 |
goods | 商品 | id、merchant_id、category_id、name、price、stock、status | category_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; // 金额统一 BigDecimal,precision 对应 decimal(10,2) @Column(precision = 10, scale = 2) private BigDecimal price; private Integer stock; private Integer sales; @Column(length = 255) private String image; // 详情用 TEXT,@Lob 在 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,查一页商品会连带把每个商家对象都查出来,二十条商品二十次额外查询,接口响应肉眼可见地变慢。就算改成LAZY,Thymeleaf 渲染时触发懒加载又可能抛LazyInitializationException,得再配OpenEntityManagerInView,链条越拉越长。
存 id 值的代价是拿到商品之后要再查一次商家名。这个代价在课程设计体量下可以忽略,而且完全可以接受——详情页多一次merchantRepository.findById(),代码直白,调试时 SQL 日志清清楚楚,比在懒加载和事务边界的黑匣子里猜要强。
主键生成策略用GenerationType.IDENTITY,对应 MySQL 的AUTO_INCREMENT。用AUTO在某些数据库下会走 sequence,MySQL 里表现不一致,明确写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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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,浏览器拿回一段 HTML,CSS 自然加载失败。排查方法很简单,打开浏览器开发者工具的 Network 面板,看 css 请求的状态码,如果是 302 就是被拦了。解决是在WebMvcConfig的excludePathPatterns里加上/css/**、/js/**、/img/**,或者直接在拦截器内部的白名单里补上。两处都改也行,但建议只留一处,避免以后改一漏一。
现象二:商品名、收货地址入库后变成一堆问号,或者中文乱码成䏿–‡。三个地方要同时对上:数据库表的字符集要是utf8mb4,JDBC URL 里要带characterEncoding=utf8,IDEA 的文件编码要是 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 "username=test&password=123456" curl -b cookie.txt "http://localhost:8080/api/order/list?page=1&size=10"第一条命令里的-c cookie.txt把 Session 写到文件,第二条-b cookie.txt带回去,能打通说明拦截器和 Session 都没问题。这套流程我一般会写成脚本,每改完一个接口就跑一遍,比在浏览器里点快得多。
最后留一个习惯:每次交付前,我都强制走一遍「删库 → 执行 init.sql → 全新启动 → 注册新号 → 下单 → 后台审核 → 发货」这条完整链路,中间任何一步卡住都不算完成。自己机器上跑得通不叫通,从零开始的机器上跑得通才叫通。这套系统结构不复杂,但正因为不复杂,才更容易在小地方翻车。希望帮到你。
本文还有配套的精品资源,点击获取