简介:面向计算机及相关专业本科生、研究生与初学电子商务系统开发的读者,这份课程设计文档围绕基于Spring Boot的网上购物商城系统,给出从需求分析、总体设计、数据库建模到实现与测试的完整案例,可用于课程设计参考、项目复现与答辩准备。压缩包共1个docx文件,约3.8MB,内容以系统设计说明书为主,包含E-R图、数据库表结构、用户/商家/管理员三类模块说明以及功能、性能与安全测试记录。已有76人学习下载,适合希望理解B/S结构、Java与MySQL在真实购物场景中如何落地的人群。读者可据此梳理模块划分与表结构设计,对照测试方案检查系统薄弱点,并参考文末的维护与扩展思路,为后续加入智能推荐、图像识别等方向预留空间。
1. 加购物车时价格对了,下单后价格却变了
答辩前一晚,A同学演示下单流程,商品在后台改过一次价格,结果历史订单列表里的金额跟着一起变了——订单里存的不是下单那一刻的金额,而是每次查询时从商品表里现算的。这类问题在基于 Spring Boot 的 MySQL 网上购物系统课程设计里极其常见:功能清单每一项都能点通,一碰数据一致性和角色权限就露馅。这套方案要解决的不是"页面能不能跑起来",而是三件事:管理员、商家、用户三类角色的权限边界怎么切干净,订单这类强一致数据该在哪一层定稿,以及系统测试和验收怎么写才经得起追问。适合正在做网上购物商城系统毕设或课设的计算机专业学生,也适合要交一份电商后台方案的从业者,换掉字段名和模块名就能当骨架用。
2. 需求拆解与角色边界:三套功能清单怎么切才不打架
需求分析这一章最容易被写成"系统有商品管理、订单管理、用户管理"这样的名词罗列。真正决定后面代码长什么样的,是三件事:每个角色能看到什么、每个字段谁能改、以及哪些操作必须登录。把这三件事在文档里写死,后面的表结构、接口、拦截器基本都是顺着推下来的。
2.1 管理员、商家、用户的功能清单与权限矩阵
先把三个角色的用例盘清楚,再谈界面。管理员侧是全局视角:个人中心、用户管理、商家管理、商品分类管理、商品信息管理、我的收藏管理、系统管理、订单管理。注意"商家管理"和"商品信息管理"这两个,管理员拥有的是审核和处置权,不是替商家上架商品——这个区分不写清楚,后面很容易做成管理员和商家共用一套商品编辑页,权限就糊了。
商家侧的清单明显更窄:注册登录、个人中心、商品信息管理、我的收藏管理、订单管理。商家的商品管理只作用于自己名下的记录,所以商品表里必须有一个"所属商家账号"字段,所有商家侧的查询都要带上这个条件。这是整个系统里最容易被忽略的一条,漏了就是越权,别的商家能改你的价。
用户侧是前台视角:首页、商品信息浏览、注册登录、购物车、个人中心、我的地址、我的收藏管理、订单管理,另外加一个客服入口。用户对商品只有读权限,对订单只有创建和查看自己订单的权限。
把这些压成一张矩阵表放进设计文档,比写五页文字管用:
| 功能点 | 管理员 | 商家 | 用户 |
|---|---|---|---|
| 用户信息 | 增删改查 | 无 | 仅本人查看与修改 |
| 商家信息 | 审核、禁用 | 仅本人资料 | 无 |
| 商品分类 | 增删改查 | 只读(上架时选择) | 只读 |
| 商品信息 | 审核、下架、删除 | 本人商品增删改查 | 浏览、搜索 |
| 订单 | 全量查看、状态干预 | 仅本人商品的订单 | 仅本人订单 |
| 收藏 | 全量管理 | 本人收藏 | 本人收藏 |
| 地址 | 无 | 无 | 本人地址增删改查 |
| 系统公告 | 增删改查 | 只读 | 只读 |
这张表的价值在于,它直接对应到后面拦截器的路径规则:/admin/**、/merchant/**、/user/**三组前缀,各自校验登录态加角色。写文档的时候顺手把路径规划出来,实现阶段几乎不用再想。
2.2 非功能需求怎么从形容词变成可验收的数字
原文提到的存储性、易学性、数据要求、稳定性、可靠性,如果只写"系统要稳定可靠",答辩时老师一句"你怎么证明"就答不上来。我的习惯是每一条都配一个可测的指标,哪怕指标定得保守,也比形容词强。
存储性对应到数据量和响应时间:单表按一万条商品、五万条订单估算,商品列表分页查询单页响应控制在 200 毫秒以内,靠的是在分类字段、商家账号字段和创建时间上建索引。易学性对应到操作路径:从登录后首页到达任意一个功能,点击次数不超过三次,界面上每个表单都带占位提示。数据要求对应到完整性约束:删除商品分类时不能连带删除商品,要么加外键限制,要么改成逻辑删除。稳定性对应到连续运行:模拟连续两小时下单、查询交替操作,接口错误率低于百分之一,内存不持续上涨。可靠性对应到三条硬规则——密码不能明文存、所有 SQL 走参数化、未登录请求一律拦在业务层之前。
指标写进文档有个副作用是好的:它逼着你在编码前就想清楚索引建在哪、密码怎么存。等写完再补,通常就是补一段文字交差。
2.3 可行性分析四段怎么写才不像凑字数
技术可行性别写"我是计算机专业学生,所以技术上没问题"。换成三段式:已经掌握什么(Java 语法、MySQL 基本增删改查、HTML/CSS 页面布局),需要补什么(Spring Boot 的自动配置机制、JPA 的实体映射、会话管理),补的路径是什么(官方文档、开源示例、按模块拆解练手)。这么写既诚实又能体现规划能力,比空喊有信心强得多。
经济可行性列清单最省事:开发工具用社区版,数据库用开源版,前端模板用开源 UI 库,服务器用本机环境,全部零成本。把每一项的名字列出来,一行一个,比一段"成本很低"有说服力。
操作可行性聚焦界面:所有功能入口在菜单里可见,表单提交失败有明确提示,删除操作二次确认,列表页有搜索和分页。这三条做到,就可以写"无需培训即可上手"。
时间可行性必须有排期。按六周算:第一周需求与选型,第二周数据库设计与建表,第三到四周核心模块编码,第五周联调与页面完善,第六周测试与文档。排期表本身就是可行性论证,比任何形容词都硬。
3. 架构与数据库设计:B/S 三层结构落到六张核心表
架构和数据库这两块如果分开写,很容易出现"架构图画得很漂亮,表结构对不上"的情况。我一般把它们放在同一章连着推:先定分层,再定每层负责什么数据,最后落到表。这样读者顺着读下来,能看清一个字段为什么长这样。
3.1 为什么这套系统选 Spring Boot 加 MySQL
课程设计里常见的另一条路是传统 SSM,手写一堆 XML 配置。选 Spring Boot 的核心理由只有一个:把时间花在业务上而不是配置上。内嵌 Tomcat 省掉单独部署容器的步骤,起步依赖把 Web、数据访问、模板引擎一次性拉齐,自动配置把数据源、事务管理器、JSON 序列化都按约定装好。对一个六周周期的课设来说,这能省出至少三天。
MySQL 这边,选它的原因也很实在:开源、体积小、安装配置简单、可视化客户端多,学生机器上跑得动。它支持事务,订单扣库存这种操作必须靠事务兜底。原文提到的多线程、多用户、开放源码这些特性,落到项目里其实就是两句话——并发下单时靠行锁和事务保证不超卖,数据文件可以直接拷走做备份。
有一点要说清楚:Spring Boot 不是"不用懂 Spring"。自动配置背后的条件装配、Bean 生命周期、事务传播行为,答辩时问到的概率很高。我的建议是至少把@Transactional的传播级别和失效场景弄明白,这个后面避坑那一章会专门讲。
3.2 B/S 三层结构与工程包结构的一一对应
B/S 结构的工作方式是浏览器发请求、服务器给响应,客户端不装专用软件。落到代码上就是三层:表现层负责接收请求和渲染页面,业务层负责规则和事务,持久层负责和数据库对话。三层之间单向依赖,表现层不直接碰数据库,这是硬约束。
工程包结构按这个分层切:
com.example.shop ├── controller 表现层:接收参数、校验、返回视图或 JSON │ ├── admin 管理员端接口,路径 /admin/** │ ├── merchant 商家端接口,路径 /merchant/** │ └── user 用户端接口,路径 /user/** ├── service 业务层:事务边界都在这一层 │ └── impl ├── repository 持久层:继承 JpaRepository 的接口 ├── entity 实体类:与数据库表一一对应 ├── config 配置类:拦截器注册、跨域、静态资源 ├── interceptor 登录与角色拦截 └── common 统一返回体、异常定义、工具类这个结构的好处是可预期:要找某个功能的实现,先看它属于哪个角色,再进对应 controller,业务逻辑一定在 service 里,SQL 一定在 repository 里。评审或者答辩被问到"这个功能在哪实现的",三十秒内能翻出来。
数据流是这样的:浏览器请求打进 controller,controller 做参数格式校验后调 service,service 里开事务、调一到多个 repository,组装结果返回,controller 再决定渲染模板还是返回 JSON。异常统一往上抛,由全局异常处理器转成友好提示,页面不会崩出一堆堆栈。
3.3 从 E-R 图到表:五类实体怎么落成六张核心表
原文档的 E-R 图覆盖了商品分类、商家、订单、管理员、商品五类实体。落到物理表的时候,有五类实体是必须的,另外三张辅助表要根据功能清单补出来——购物车、收货地址、收藏。这三张在 E-R 图里没画,但功能列表里有对应入口,不建表就只能把数据塞进会话,刷新一次就没了。
实体识别有个简单判断标准:如果它有独立的生命周期、会被单独查询、并且有多个属性,就该独立成表。购物车满足这三条——用户会反复查看、按用户查、有数量和加入时间,所以独立建表。收藏同理。地址如果不独立建表,用户下单时就没法从历史地址里选,只能每次手填,体验直接掉一档。
订单这块有个设计取舍值得单独说。原文档的订单实体把商品名称、商品图片、商品价格都放进了订单表,也就是一笔订单对应一件商品。这在课设里是合理的简化,好处是查询简单、不用连表。缺点是用户一次买三件商品就得生成三笔订单,支付和退款都会变复杂。如果时间允许,我建议拆成订单主表和订单明细表:主表存订单号、用户、地址、总金额、状态、下单时间,明细表存商品、单价、数量、小计。多花半天时间,换来的是整个订单模块的扩展性,后面要加优惠券、拆单发货都不用改结构。
不管拆不拆,有一条必须守住:订单里的价格是快照,来源于下单那一刻的商品价格,之后商品调价、改名、下架,都不影响历史订单。这就是第 1 章开头那个问题的根因。
3.4 关键配置与三张核心表的建表语句
配置这块,数据源和 JPA 是最容易出问题的地方。先看application.yml:
server: port: 8080 servlet: session: timeout: 30m # 会话半小时过期,演示时够用,别设太长 spring: datasource: url: "jdbc:mysql://127.0.0.1:3306/shop_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai" username: shop password: shop123 driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 # 课设环境 10 个连接足够,别开太大 connection-timeout: 30000 jpa: hibernate: ddl-auto: validate # 关键:只校验不自动改表,防止误改字段丢数据 show-sql: true properties: hibernate: format_sql: true open-in-view: false # 关掉,避免懒加载在视图层报错ddl-auto这个参数是重点。很多人图省事用update,结果实体类改个字段名,启动时表结构跟着变,历史数据直接丢。改成validate之后,实体和表对不上会启动失败,反而能第一时间发现问题。建表统一走 SQL 脚本,团队协作和答辩演示都更可控。
再看三张关键表。商品表承载了分类、商家、审核状态三类信息:
CREATE TABLE goods ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', merchant_id BIGINT NOT NULL COMMENT '所属商家,商家侧查询必带此条件', category_id BIGINT NOT NULL COMMENT '商品分类', name VARCHAR(120) NOT NULL COMMENT '商品名称', cover_img VARCHAR(255) DEFAULT NULL COMMENT '主图地址', detail TEXT COMMENT '详情,富文本', price DECIMAL(10,2) NOT NULL COMMENT '当前售价,订单快照的来源', stock INT NOT NULL DEFAULT 0 COMMENT '库存', sales INT NOT NULL DEFAULT 0 COMMENT '月售销量', audit_status TINYINT NOT NULL DEFAULT 0 COMMENT '0待审 1通过 2驳回', audit_remark VARCHAR(255) DEFAULT NULL COMMENT '审核回复', clicks INT NOT NULL DEFAULT 0 COMMENT '点击次数', is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_category (category_id), KEY idx_merchant (merchant_id), KEY idx_audit (audit_status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品信息表';金额字段用DECIMAL(10,2)而不是DOUBLE,这一点后面避坑章会展开。库存字段独立存在,扣减时配合条件更新使用。is_deleted逻辑删除标记是为了让商品下架后历史订单仍能查到商品名,这个问题同样在后面细说。
订单表的关键在于快照字段:
CREATE TABLE orders ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT '订单号,业务唯一', user_id BIGINT NOT NULL COMMENT '下单用户', merchant_id BIGINT NOT NULL COMMENT '订单归属商家,商家端按此过滤', goods_id BIGINT NOT NULL COMMENT '商品主键', goods_name VARCHAR(120) NOT NULL COMMENT '商品名快照', goods_img VARCHAR(255) DEFAULT NULL COMMENT '商品图快照', price DECIMAL(10,2) NOT NULL COMMENT '成交单价快照', quantity INT NOT NULL COMMENT '购买数量', total_amount DECIMAL(10,2) NOT NULL COMMENT '订单总额', address VARCHAR(255) NOT NULL COMMENT '收货地址快照', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待付款 1已付款 2已发货 3已完成 4已取消', pay_type TINYINT DEFAULT NULL COMMENT '支付方式', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user (user_id), KEY idx_merchant_status (merchant_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';goods_name、goods_img、price、address这四个快照字段是这个表设计的核心。商品改名、调价、下架,订单里的信息岿然不动。地址同理,用户改了自己的默认收货地址,历史订单上的地址不能跟着变。
购物车表相对简单,但要加唯一约束防止同一商品重复加购:
CREATE TABLE cart ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, goods_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 1, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_goods (user_id, goods_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='购物车表';uk_user_goods这个唯一索引让"同一用户同一商品只保留一条记录"变成数据库层面的约束,业务代码里只需要判断"存在就加数量,不存在就插入",不用先查再插,少一次往返也少了并发下的重复插入风险。
4. 核心模块实现:注册登录、购物车、订单三段主链路
功能列表有十几项,真正决定系统能不能立住的是三段主链路:注册登录管住入口,购物车管住过程,订单管住结果。这三段写扎实,其余的商品浏览、收藏、地址管理都是同类增删改查,照葫芦画瓢即可。
4.1 注册登录:唯一性校验与密码存储
注册流程原文写得很清楚:判断用户名是否已存在,存在则提示重填,不存在则校验其他信息,通过后写入数据库。这个逻辑不能只在页面上做,必须在服务端做,而且要依赖数据库唯一索引兜底——页面校验能绕过,唯一索引绕不过。
密码存储这条线,我的态度很明确:课程设计也不要存明文。用 Spring Security 提供的BCryptPasswordEncoder就行,引入依赖后注册时加密、登录时校验,代码量比手写 MD5 还少。
@Service public class UserService { @Resource private UserRepository userRepository; // 无状态编码器,直接作为单例使用,不需要每次 new private static final BCryptPasswordEncoder ENCODER = new BCryptPasswordEncoder(); public void register(String username, String rawPassword, String phone) { // 1. 唯一性校验:数据库唯一索引是最后一道闸,这里先给出友好提示 if (userRepository.existsByUsername(username)) { throw new BizException("用户名已存在,请更换"); } User user = new User(); user.setUsername(username); // 2. 加盐哈希,同一个密码每次加密结果都不同 user.setPassword(ENCODER.encode(rawPassword)); user.setPhone(phone); user.setCreateTime(LocalDateTime.now()); userRepository.save(user); } public User login(String username, String rawPassword) { User user = userRepository.findByUsername(username) .orElseThrow(() -> new BizException("用户名或密码错误")); // 3. 统一提示"用户名或密码错误",不暴露账号是否存在 if (!ENCODER.matches(rawPassword, user.getPassword())) { throw new BizException("用户名或密码错误"); } return user; } }三个点值得说。第一,existsByUsername需要 repository 里定义对应方法,Spring Data JPA 靠方法名自动解析,不用写 SQL。第二,BCryptPasswordEncoder每次加密都会带随机盐,所以同一个密码两次加密结果不同,校验时用matches,不能用字符串比较。第三,登录失败提示统一成"用户名或密码错误",别分成"账号不存在"和"密码错误"两种,后者等于告诉别人哪些账号是有效的。
登录成功后把用户信息和角色写进 session,键名固定成LOGIN_USER和ROLE,后面拦截器统一读这两个键。商家和管理员的登录走同一套逻辑,只是查的表不同、写入的角色值不同。
4.2 购物车到订单:库存扣减、金额计算与状态机
这一段是整个系统最容易出问题的地方。下单要做四件事:校验库存、扣减库存、生成订单快照、清空购物车。四件事必须在一个事务里,任何一步失败全部回滚。
核心难点是并发扣库存。先查库存再更新,两个请求可能同时读到库存 1,都判断够,最后卖出 2 件。正确做法是把判断和扣减合成一条带条件的更新语句:
// GoodsRepository 中定义,靠影响行数判断是否扣减成功 @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);这条语句在数据库层面把"库存足够"和"扣减"绑成原子操作,返回 0 就说明库存不够。再配合事务,整个流程就成了:
@Service public class OrderService { @Resource private GoodsRepository goodsRepository; @Resource private OrdersRepository ordersRepository; @Resource private CartRepository cartRepository; @Transactional(rollbackFor = Exception.class) public String createOrder(Long userId, List<Long> cartIds, String address) { List<Cart> carts = cartRepository.findAllById(cartIds); if (carts.isEmpty()) { throw new BizException("请先选择要购买的商品"); } BigDecimal totalAmount = BigDecimal.ZERO; for (Cart cart : carts) { Goods goods = goodsRepository.findById(cart.getGoodsId()) .orElseThrow(() -> new BizException("商品已下架")); // 1. 条件更新扣库存,返回 0 说明被别人抢先买走 int rows = goodsRepository.reduceStock(goods.getId(), cart.getQuantity()); if (rows == 0) { throw new BizException("商品[" + goods.getName() + "]库存不足"); } // 2. 逐字段写快照,绝不引用 goods 对象本身 Orders order = new Orders(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setMerchantId(goods.getMerchantId()); order.setGoodsId(goods.getId()); order.setGoodsName(goods.getName()); order.setGoodsImg(goods.getCoverImg()); order.setPrice(goods.getPrice()); order.setQuantity(cart.getQuantity()); order.setTotalAmount(goods.getPrice().multiply(BigDecimal.valueOf(cart.getQuantity()))); order.setAddress(address); order.setStatus(OrderStatus.WAIT_PAY.getCode()); order.setCreateTime(LocalDateTime.now()); ordersRepository.save(order); totalAmount = totalAmount.add(order.getTotalAmount()); } // 3. 清空已下单的购物车记录 cartRepository.deleteAllById(cartIds); return totalAmount.toPlainString(); } }参数说明几个关键点。@Transactional(rollbackFor = Exception.class)里的rollbackFor不能省,Spring 默认只对运行时异常回滚,抛受检异常时不回滚,库存扣了订单没生成就是这么来的。BigDecimal做乘法时必须用multiply,不能用*,而且BigDecimal.valueOf(int)是安全的,直接new BigDecimal(double)会带出一串精度尾巴。
订单状态不要用字符串硬编码,用枚举:
public enum OrderStatus { WAIT_PAY(0, "待付款"), PAID(1, "已付款"), SHIPPED(2, "已发货"), FINISHED(3, "已完成"), CANCELED(4, "已取消"); private final int code; private final String desc; // 构造与 getter 省略 }状态流转也要有约束:待付款只能转已付款或已取消,已付款只能转已发货,跳着转一律拒绝。取消订单时要把库存加回去,这一条同样得写进事务里。没有这层约束,测试时就可能出现"已完成的订单又被取消,库存加了两遍"。
4.3 商品检索与分类联动:分页查询的写法
商品列表是访问量最大的页面,写法上要注意三点:只查上架且审核通过的商品、按分类过滤、分页。
@GetMapping("/list") public Page<Goods> list(@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "12") int size, @RequestParam(required = false) Long categoryId, @RequestParam(required = false) String keyword) { Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createTime")); // 审核通过 + 未删除是固定条件,不能交给前端传 return goodsService.pageOnline(categoryId, keyword, pageable); }page从 0 开始,这一点和前端常见的从 1 开始不一样,接口文档里必须写清楚,否则第一页数据永远看不到。size加个上限,比如超过 100 就按 100 处理,防止有人传size=999999把内存打爆。排序字段用创建时间倒序,新品靠前,符合购物场景。分类和关键词都是可选条件,用required = false,在 service 里拼动态查询或者用 JPA 的 Specification。
分类这块有个联动细节:删除分类时不能直接删,要先检查该分类下有没有商品,有就拒绝并提示。直接把分类删了、商品的外键悬空,商品列表按分类筛选时会整片消失。稳妥做法是分类也加逻辑删除,或者干脆不允许删除有商品的分类。
4.4 登录拦截与角色鉴权:一个拦截器管三类路径
前面权限矩阵里规划的三组路径前缀,就是在这里落地的。写一个HandlerInterceptor,在preHandle里做两件事:查登录态、查角色。
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(false); Object loginUser = (session == null) ? null : session.getAttribute("LOGIN_USER"); // 1. 未登录统一拦截,返回 JSON 让前端跳登录页 if (loginUser == null) { writeJson(response, 401, "请先登录"); return false; } // 2. 按路径前缀校验角色,三组路径互不越界 String uri = request.getRequestURI(); String role = String.valueOf(session.getAttribute("ROLE")); if (uri.startsWith("/admin/") && !"ADMIN".equals(role)) { writeJson(response, 403, "无权限访问"); return false; } if (uri.startsWith("/merchant/") && !"MERCHANT".equals(role)) { writeJson(response, 403, "无权限访问"); return false; } return true; } private void writeJson(HttpServletResponse resp, int code, String msg) throws IOException { resp.setContentType("application/json;charset=UTF-8"); resp.getWriter().write("{\"code\":" + code + ",\"msg\":\"" + msg + "\"}"); } }getSession(false)的意思是"有会话就取,没有就返回 null",不主动创建。用getSession()会在每次未登录请求时都建一个空会话,白白占内存。注册拦截器时通过addPathPatterns圈定范围,把登录页、注册页、静态资源、商品浏览接口排除掉,否则用户连首页都看不到。
角色这块只做了粗粒度拦截。更严的做法是在 service 层再校验一次数据归属——商家查询订单时,SQL 条件里必须带merchant_id = 当前登录商家,不能只靠路径拦截。路径拦截挡的是"商家访问管理员接口",挡不住"商家 A 查商家 B 的订单"。
5. 避坑与常见问题排查:五个最容易翻车的地方
下面这五条基本都是我在类似项目里真实踩过或者看别人踩过的。现象、原因、解决三段式写,照着对一遍,能省掉答辩现场大半的尴尬。
5.1 金额字段用了 double,对账永远差几分
现象:购物车里三件商品,单价 19.9,页面显示合计 59.7,订单表里存的是 59.699999999999996,导出报表或者做统计时金额对不上。
原因:double是二进制浮点数,很多十进制小数在二进制里是无限循环的,存进去就已经不精确,再参与运算误差会累积。Java 的float和double都不适合做金额计算。
解决:数据库统一用DECIMAL(10,2),Java 侧统一用BigDecimal。BigDecimal做除法时必须指定精度和舍入模式,比如divide(new BigDecimal("3"), 2, RoundingMode.HALF_UP),不指定会抛ArithmeticException。另外比较金额大小用compareTo,不要用equals——new BigDecimal("1.0")和new BigDecimal("1.00")的equals返回 false,因为精度不同。
5.2 商品用了物理删除,历史订单里的商品名变成空白
现象:商家下架并删除某个商品后,用户翻历史订单,商品名、图片全空了,订单列表出现一片空白行。
原因:订单表通过商品 id 关联商品表,查询时做连表或者实时查商品信息,商品被物理删除后查不到。更糟的是如果加了外键约束,删除商品直接报错,商家那边又删不掉。
解决:商品表加is_deleted字段做逻辑删除,删除操作改成update goods set is_deleted = 1,所有查询默认带is_deleted = 0。同时订单表里的商品名、图片、价格必须存快照,就是 3.4 节那几个字段。这两条一起做,历史订单才真正不受影响。购物车里的商品被删除时,查询要过滤掉,或者在购物车列表里标成"已失效"。
5.3 订单状态用字符串硬编码,改一个状态要动五个文件
现象:一开始状态写的是"待付款"、"已付款",后来要加"退款中",前端页面、后端判断、数据库里的存量数据全得改,改漏一处就出现状态显示空白。
原因:状态值散落在 controller 的 if 判断、service 的流转校验、页面模板的条件渲染、数据库的存量数据里,没有唯一来源。
解决:定义OrderStatus枚举,code 存数据库、desc 给页面显示,所有判断都引用枚举常量。状态流转集中到一个方法里,用Map<Integer, Set<Integer>>或者 switch 定义合法流转,非法流转统一抛异常。这个改动成本很低,但能让后面加状态变得非常轻松。
5.4 前端传什么后端就存什么,改价一测一个准
现象:下单接口接收前端传来的price和totalAmount直接入库,用浏览器开发者工具改一下请求参数,1 块钱买到原价 999 的商品。
原因:把前端当成可信来源。前端的任何参数都可以被篡改,尤其是价格、库存、用户 id、订单状态这类敏感字段。
解决:价格一律由后端从商品表读取,前端只传商品 id 和数量。用户 id 从 session 里取,不接收前端传的userId。订单状态由后端按流程设置,创建订单时固定为待付款,不接受前端指定。商家操作的接口,merchant_id从 session 取,SQL 条件里带上,防止改别人的商品。这几条是底线,一条都不能省。
5.5 测试只跑正向流程,答辩被一个边界值问住
现象:演示时一切顺利,老师随手输入一个空格、一个负数、一个超长字符串,系统直接 500 报错页面。
原因:测试用例只覆盖了"正常下单成功"这类主流程,边界值和异常输入没测。很多课设的测试章节写的全是"点击按钮,功能正常",这种描述老师一眼就能看出没真跑过。
解决:每个功能至少准备四类用例——正常值、边界值、异常值、权限越界。比如数量输入:正常 1 件、边界 0 件和库存上限、异常 -1 件和 99999 件、越界是未登录直接调接口。后端每个入参都加校验注解,比如@Min(1)、@NotBlank、@Size(max = 120),配合@Valid生效,校验失败统一返回提示而不是 500。这一条在第 6 章还会展开成完整的测试方案。
6. 验收与答辩:系统测试怎么写,演示怎么讲
测试章节是课设文档里最容易糊弄也最容易被追问的部分。我的写法是先把测试方法说明白,再上用例表,最后给结论。方法这块,功能测试用黑盒,等价类划分加边界值分析,不关心内部怎么实现,只看输入输出是否符合预期;兼容性测试至少覆盖两种浏览器;性能测试做一个简单的并发压测,用工具模拟 50 个用户同时访问商品列表和下单接口,记录响应时间和错误率。不需要做到生产级,但要能说出数据。
用例表按模块分组,每条写清楚前置条件、操作步骤、预期结果、实际结果。给个样例:
| 编号 | 模块 | 前置条件 | 操作步骤 | 预期结果 |
|---|---|---|---|---|
| TC-01 | 注册 | 用户名已存在 | 输入已注册用户名提交 | 提示"用户名已存在" |
| TC-02 | 注册 | 无 | 密码输入 5 位 | 提示密码长度不足 |
| TC-03 | 登录 | 已注册 | 密码输错 | 提示"用户名或密码错误" |
| TC-04 | 购物车 | 已登录 | 同一商品加购两次 | 数量变为 2,不新增记录 |
| TC-05 | 下单 | 库存为 1 | 两个会话同时下单 | 一个成功,一个提示库存不足 |
| TC-06 | 下单 | 已登录 | 篡改请求中的价格参数 | 订单金额仍为商品表价格 |
| TC-07 | 权限 | 商家登录 | 直接访问 /admin/user/list | 返回 403,不返回数据 |
| TC-08 | 订单 | 已下单 | 商家修改商品价格 | 历史订单金额不变 |
TC-05 和 TC-06 这两条是加分项,很多同学习惯性跳过并发和越权测试,你能拿出来讲,说明想过真实场景。
答辩演示有个顺序讲究。别一上来就翻代码,先用三分钟讲清楚"这个系统解决什么问题、分几个角色、每个角色能做什么",配那张权限矩阵表。然后按一条完整业务线走:商家注册登录、发布商品、管理员审核通过、用户注册、浏览搜索、加购物车、下单、商家看到订单、发货、用户确认收货。这条线走完,系统的核心价值就展示完了。
演示过程里主动埋两个点。第一个是改价:下单后切到商家端把商品价格改掉,再回到用户端刷新订单详情,金额纹丝不动,这时候说一句"订单里存的是快照"。第二个是越权:用商家账号在地址栏直接敲管理员路径,页面返回无权限提示,说一句"路径前缀加拦截器,角色不匹配直接拦掉"。这两个动作各十秒钟,但能挡掉答辩里最难缠的两类提问。
被问到"系统还有什么不足"时,别答"功能还不够完善"这种空话。挑一个具体的技术债说,比如"订单目前是一单一商品,扩展性有限,下一步会拆成订单主表和明细表,支持一次结算多件商品",或者"库存扣减目前靠数据库行锁,并发量再上一个量级就要引入缓存和消息队列削峰"。承认边界比吹嘘完备更能体现你真的想过。
最后说个习惯。从那以后我每次做完这类系统,交之前都强制走一遍"换一台干净机器从零部署"的流程:装数据库、导 SQL、改配置、启动、跑主流程。本地能跑不代表交上去能跑,路径、端口、字符集、时区这四个地方几乎每次都会翻车一个。这一步花不了半小时,但能避免答辩当天在讲台上和一堆报错干瞪眼。希望帮到你。
本文还有配套的精品资源,点击获取