简介:面向计算机专业毕业设计、课程设计与Web开发入门练手场景,这套网上购物系统资料包以论文与源码配套的形式,给出从需求分析、系统架构设计到前后端编码实现的完整工程样例,可帮助读者解决选题缺少思路、手上没有可运行参考项目的问题。压缩包共约2000个文件、200.04MB,其中693个js脚本与275个css样式支撑页面交互,247个html与75个aspx页面构成前台商城和后台管理界面,137个cs源文件配合ashx一般处理程序承担业务逻辑,另有gif、png、jpg图片素材与mdb数据库、sln解决方案文件,共同组成一套可直接还原的技术栈。内容覆盖商品浏览、购物车管理、订单处理、用户中心、验证码与文件上传等常见模块。目前已有82人学习,适合用作毕业设计参考资料、答辩演示底稿或二次开发起点。
1. 需求先立住:网上购物系统到底要交付一条什么链路
接手「基于 web 的网上购物系统的设计与实现」这个题目的人,多数第一天就打开 IDE 建工程,花两周堆页面,最后卡在订单模块写不下去。卡住的真正原因往往不是代码能力,而是没想清楚这套系统要交付的不是「商品列表 + 下单按钮」,而是一条从浏览、加购、下单、支付、发货、售后到后台运营的完整链路,任何一环断掉,演示就断在那里。
这套方案适合两类人:要交毕设或课程设计、需要一个结构站得住又能真跑起来的学生;要出一份可交付的电商后台方案、想直接拿骨架改的从业者。下面按需求、架构、模块实现、避坑、验收的顺序往下推,表结构和关键代码都给到能照着敲的程度。
2. 总体架构与技术选型:三层结构、五个角色与十二张表的职责边界
2.1 技术栈取舍:为什么这套组合在毕设场景最不容易翻车
先把结论放这里:后端 Spring Boot + MyBatis-Plus + MySQL,前端 Vue3 + Element Plus,认证用 JWT 加拦截器,这就是当前网上购物系统设计与实现里最稳的一套。稳不是因为先进,而是因为它把「环境装不上」「配置讲不清」「答辩被问倒」这三类风险都压到了最低。
版本上有个细节值得单独说。Spring Boot 3.x 要求 JDK 17,而很多学校的机房镜像、学长传下来的工程模板、甚至某些 IDE 默认配置还停在 JDK 8。如果开题时没有硬性要求,我一般选 Spring Boot 2.7.x 配 JDK 8 或 11,省下来的时间足够把订单模块写扎实。真要上 3.x,记得把javax.*全部换成jakarta.*,这是最容易在启动时炸的地方。
持久层用 MyBatis-Plus 而不是纯 MyBatis,理由很实际:商品、分类、地址这些表的单表 CRUD 占了代码量的一大半,用BaseMapper能省掉几百行;但订单、库存这两块必须手写 SQL,因为涉及条件更新和行锁,用代码生成器生成出来的语句一定不够用。至于为什么不上微服务,一个日订单量三位数的系统拆成五个服务,除了让启动变麻烦、让答辩时被追问「服务怎么注册发现」之外没有任何收益。
# application.yml —— 关键项都有注释,改端口和库名就能跑 server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver # useUnicode + utf8mb4 是商品名带表情、生僻字不乱码的前提 url: jdbc:mysql://localhost:3306/shop_mall?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 123456 servlet: multipart: max-file-size: 5MB # 商品主图上限,超了前端要给出明确提示 max-request-size: 20MB mybatis-plus: configuration: map-underscore-to-camel-case: true # create_time -> createTime log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发期打印 SQL,上线关掉 global-config: db-config: logic-delete-field: deleted # 全表统一逻辑删除字段 logic-delete-value: 1 logic-not-delete-value: 0 shop: jwt: secret: mall-token-secret-2024 # 生产环境务必换成随机长串 expire: 604800 # 7 天,单位秒 order: auto-cancel-minutes: 30 # 未支付自动关单时间这段配置里有三个参数经常被改错。serverTimezone不写或写错,订单时间会整体偏移 8 小时,答辩演示时「下单时间」显示成前一天,很尴尬;map-underscore-to-camel-case关掉之后,所有create_time字段在实体里都是 null,排查半天以为是 SQL 写错了;logic-delete-field一旦开启,全表都得有deleted字段,中途加表忘了加就会报「Unknown column」,所以建表脚本要一次给全。
前端那边,Vue3 + Vite 的启动速度比 Vue2 + webpack 快一个量级,Element Plus 的表格、分页、上传组件能直接覆盖后台 80% 的界面需求。路由用vue-router,状态用 Pinia 存用户信息和购物车角标,请求统一走一个封装过的 Axios 实例,把 token 塞进请求头、把后端的统一返回结构解包、把 401 跳登录集中处理。这三件事集中在一个文件里做完,后面每个页面都能少写十几行。
2.2 分层与目录:controller 不写业务,service 管事务边界
工程结构乱是很多购物系统后期改不动的根源。常见的情况是 Controller 里直接注入 Mapper,一个方法写八十行,查商品、算价格、扣库存、发消息全挤在一起,等到要加一个「下单送积分」的需求,发现得改五个地方。规范的做法是三层各守一件事:Controller 只做参数接收、参数校验、调用 Service、包装返回;Service 承担业务编排和事务边界;Mapper 只负责 SQL,不出现任何 if-else 的业务判断。
shop-mall ├── src/main/java/com/mall │ ├── common # 统一返回 R、全局异常、常量、工具类 │ ├── config # 跨域、拦截器、MyBatis-Plus、文件上传映射 │ ├── controller # 只做参数校验与转发 │ ├── service # 接口 + impl,事务加在 impl 上 │ ├── mapper # 继承 BaseMapper,复杂 SQL 写在 XML │ ├── entity # 与表一一对应 │ └── dto / vo # 入参出参与实体解耦,别拿 entity 直接当返回 └── src/main/resources ├── mapper # 手写 SQL 的 XML └── application.ymlcommon包里那个统一返回结构值得单独强调。前端解包只认一种格式,后面加接口就不用每个页面判断一次。
// common/R.java —— 全站统一返回,code=200 表示业务成功 public class R<T> { private Integer code; private String msg; private T data; public static <T> R<T> ok(T data) { R<T> r = new R<>(); r.code = 200; r.msg = "success"; r.data = data; return r; } public static <T> R<T> fail(String msg) { R<T> r = new R<>(); r.code = 500; r.msg = msg; return r; } // getter/setter 省略 }Controller 层照着这个模板写,一个接口十行以内,业务判空全部下沉到 Service,异常交给@RestControllerAdvice统一捕获。这样做的直接好处是:答辩时被问「如果扣库存失败会怎样」,你能指着全局异常处理器说「事务回滚,接口返回 500 加具体原因,前端弹提示」,而不是现场翻代码。
2.3 数据库设计:十二张表里只有四张真正难设计
整个系统拆下来是十二张表:八张业务表加四张权限表。真正需要动脑子的只有商品、订单、订单明细、购物车这四张,其余都是标准结构。
| 表名 | 作用 | 关键字段 | 设计要点 |
|---|---|---|---|
| ums_user | 前台会员 | username、password、phone、status | 密码存 BCrypt,不存明文 |
| ums_address | 收货地址 | user_id、receiver、phone、detail、is_default | 一人多址,默认址唯一 |
| pms_category | 商品分类 | name、parent_id、sort、level | 两级足够,别做无限级 |
| pms_product | 商品主表(SPU) | name、category_id、cover、detail、status | 只存公共信息,不存价格 |
| pms_sku | 规格库存 | product_id、spec、price、stock | 库存和价格放这里 |
| oms_cart_item | 购物车 | user_id、sku_id、quantity、checked | 加唯一索引 (user_id, sku_id) |
| oms_order | 订单主表 | order_no、user_id、total_amount、status、pay_time | 金额 decimal(10,2) |
| oms_order_item | 订单明细 | order_id、sku_id、price、quantity、snapshot | 下单时存快照,商品改价不影响历史单 |
订单表是整套设计的地基,字段少一个后面就要改表。
CREATE TABLE `oms_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '业务订单号,对外展示', `user_id` bigint NOT NULL, `total_amount` decimal(10,2) NOT NULL COMMENT '商品总额', `pay_amount` decimal(10,2) NOT NULL COMMENT '应付金额=总额+运费-优惠', `freight` decimal(10,2) DEFAULT 0.00, `status` tinyint NOT NULL DEFAULT 0 COMMENT '0待付款 1待发货 2待收货 3已完成 4已取消', `receiver_name` varchar(32) NOT NULL COMMENT '地址快照,不关联地址表', `receiver_phone` varchar(20) NOT NULL, `receiver_detail` varchar(255) NOT NULL, `pay_time` datetime DEFAULT NULL, `delivery_time` datetime DEFAULT NULL, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `deleted` tinyint NOT NULL DEFAULT 0, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_status` (`user_id`,`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';有两个决定容易被忽略。一是收货地址必须做快照写进订单表,而不是只存address_id——用户改了自己地址簿里的电话,历史订单的收货信息不能跟着变。二是order_no和主键id分开,主键自增用于内部关联,订单号用「年月日时分秒 + 用户 ID 后四位 + 随机数」生成,对外展示不暴露真实单量,这个细节在答辩时是加分项。
3. 核心模块实现:商品、购物车、下单、状态机四段主线
3.1 商品与库存:SPU/SKU 拆分的落点就是扣减那一行 SQL
商品模块的难点不在展示,在「一件商品有多个规格」这件事上。同样是手机,8G+128G 和 12G+256G 是两个价格、两份库存,如果只建一张商品表,price 和 stock 字段就没法填。所以拆成两张:pms_product存标题、主图、详情、分类、上下架状态这些所有规格共享的信息;pms_sku存规格描述、价格、库存,通过product_id关联。前台详情页查一次商品、查一次规格列表,用户选中规格后拿到skuId,加购和下单全程用skuId。
库存扣减是整个系统最不能出错的地方,也是最常见的超卖来源。错误的写法是先select stock from pms_sku where id = ?,在 Java 里判断stock >= num,再update ... set stock = ?。两个请求同时进来,都读到 10,都判断通过,都写回 9,实际卖了两件只扣了一件。正确做法是把判断塞进where条件里,靠数据库的行锁和更新影响行数来兜底。
-- 扣减库存:判断和更新在同一条语句里完成,返回影响行数 UPDATE pms_sku SET stock = stock - #{num} WHERE id = #{skuId} AND stock >= #{num};Service 层拿到返回值后判断:影响行数为 0 说明库存不足,直接抛业务异常,外层事务回滚,整个下单失败。这里的注释必须写清楚「返回 0 不代表 SQL 出错,代表库存不够」,不然后面接手的人会把它当成异常分支删掉。
提示:
stock >= #{num}这个条件不能省,哪怕前面已经查过一次库存。前置查询只是为了给用户友好提示,真正的并发防线只有这一行。
3.2 购物车:登录态与游客态的合并策略
购物车有两个入口,登录用户和未登录游客。游客的购物车放在浏览器 localStorage 里,结构是[{skuId, quantity, checked}];登录用户的存在oms_cart_item表里。用户在游客状态下挑了三件商品然后去登录,这三件不能丢,否则体验上就是「登录一下购物车清空了」。
合并逻辑放在登录成功之后立即执行一次,规则定死三条:同一个skuId数量相加、单条数量上限 99、已下架或库存为 0 的 SKU 直接丢弃并返回提示文案。
// 登录后合并游客购物车:同 SKU 累加,超过上限截断 @Transactional(rollbackFor = Exception.class) public void mergeCart(Long userId, List<CartItemDTO> guestItems) { if (CollectionUtils.isEmpty(guestItems)) return; for (CartItemDTO item : guestItems) { Sku sku = skuMapper.selectById(item.getSkuId()); if (sku == null || sku.getStock() <= 0) continue; // 失效商品不合并 CartItem exist = cartMapper.selectOne( new LambdaQueryWrapper<CartItem>() .eq(CartItem::getUserId, userId) .eq(CartItem::getSkuId, item.getSkuId())); if (exist == null) { CartItem cart = new CartItem(); cart.setUserId(userId); cart.setSkuId(item.getSkuId()); cart.setQuantity(Math.min(item.getQuantity(), 99)); // 上限 99 cart.setChecked(1); cartMapper.insert(cart); } else { int total = exist.getQuantity() + item.getQuantity(); exist.setQuantity(Math.min(total, 99)); cartMapper.updateById(exist); } } }合并完成后前端要做的动作是清空 localStorage,并且重新拉一次购物车列表刷新角标。这一步经常被漏掉,结果是用户登录后角标显示 5,点进去只有 3 件,看着像 bug。oms_cart_item表上给(user_id, sku_id)建唯一索引,可以顺手把并发登录导致的重复插入也挡掉。
3.3 下单事务:一次 createOrder 里必须做完的六件事
下单接口是整套系统的核心,写不好会出现「钱扣了没订单」「订单有了库存没扣」「购物车清了订单没生成」这些经典问题。标准做法是把六件事放进同一个事务,按顺序执行,任何一步失败整体回滚。
顺序是:校验并重算金额 → 扣库存 → 写订单主表 → 写订单明细 → 清购物车 → 返回订单号。金额重算这一步必须强调,前端传来的totalAmount一律不信任,只能拿skuId和数量回表查当前价格自己算。
@Transactional(rollbackFor = Exception.class) // 必须写 rollbackFor,否则受检异常不回滚 public String createOrder(Long userId, OrderSubmitDTO dto) { List<CartItem> items = cartMapper.selectByUserAndChecked(userId); if (items.isEmpty()) throw new BizException("请先选择商品"); BigDecimal goodsAmount = BigDecimal.ZERO; List<OrderItem> orderItems = new ArrayList<>(); for (CartItem item : items) { Sku sku = skuMapper.selectById(item.getSkuId()); if (sku == null || sku.getStatus() == 0) throw new BizException("商品已下架"); // 1. 服务端重算金额,前端传的价格一律不用 goodsAmount = goodsAmount.add(sku.getPrice().multiply(new BigDecimal(item.getQuantity()))); // 2. 条件更新扣库存,返回 0 直接抛异常触发回滚 int rows = skuMapper.deductStock(sku.getId(), item.getQuantity()); if (rows == 0) throw new BizException("商品【" + sku.getSpec() + "】库存不足"); // 3. 组装明细快照,价格和标题都存下来 OrderItem oi = new OrderItem(); oi.setSkuId(sku.getId()); oi.setPrice(sku.getPrice()); oi.setQuantity(item.getQuantity()); oi.setProductName(sku.getProductName()); orderItems.add(oi); } // 4. 写订单主表 Order order = new Order(); order.setOrderNo(OrderNoUtil.generate(userId)); order.setUserId(userId); order.setTotalAmount(goodsAmount); order.setPayAmount(goodsAmount.add(freight)); order.setStatus(0); orderMapper.insert(order); // 5. 写明细,批量插入别用 for 循环单条 insert orderItems.forEach(oi -> oi.setOrderId(order.getId())); orderItemService.saveBatch(orderItems); // 6. 清掉已勾选的购物车项 cartMapper.deleteByUserAndChecked(userId); return order.getOrderNo(); }参数上要盯住两处。@Transactional的rollbackFor = Exception.class不能省,Spring 默认只对RuntimeException回滚,业务里抛了受检异常事务是不会回滚的,这个坑我见过不止一次。运费freight通常单独算:满 X 元包邮,否则按件计费,规则写在配置里而不是硬编码在方法体内,后面改活动价不用动代码。
注意:
deductStock必须写在 XML 里手写 SQL,不要用 MyBatis-Plus 的updateById。前者是stock = stock - n,后者是stock = 新值,语义完全不同。
3.4 订单状态机与超时关单:状态只能单向流动
订单状态用 tinyint 存,0 待付款、1 待发货、2 待收货、3 已完成、4 已取消。关键在于所有状态变更必须走同一个校验方法,而不是各个接口里随手setStatus。曾经见过发货接口把已取消的订单改成待发货,用户白拿一件货,这就是没有状态机的代价。
做法很简单:写一个OrderStatus枚举,配一个Map<Integer, List<Integer>>存合法流转关系,任何变更前先查一次。待付款只能去待发货或已取消,待发货只能去待收货,已取消和已完成是终态,不允许再改。
超时关单靠定时任务。用户下单后 30 分钟不付款,订单要自动取消并把库存还回去,否则商品会被一直占着。用@Scheduled每 5 分钟扫一次,条件写status = 0 and create_time < now() - 30min。
@Scheduled(cron = "0 */5 * * * ?") // 每 5 分钟扫一次 public void cancelTimeoutOrders() { LocalDateTime deadline = LocalDateTime.now().minusMinutes(30); List<Order> list = orderMapper.selectList(new LambdaQueryWrapper<Order>() .eq(Order::getStatus, 0) .lt(Order::getCreateTime, deadline) .last("limit 100")); // 分批处理,别一次全捞出来 for (Order order : list) { // 状态校验放在 update 条件里,避免和用户支付动作撞车 int rows = orderMapper.updateStatus(order.getId(), 0, 4); if (rows == 0) continue; // 已被支付或已取消,跳过 orderItemMapper.restoreStock(order.getId()); // 回滚库存 } }updateStatus的 SQL 写成update oms_order set status = 4 where id = ? and status = 0,靠影响行数判断是否抢到了这次关单。用户正好在这一秒付款,支付回调先把状态改成 1,定时任务的影响行数就是 0,直接跳过,不会出现「付了钱订单被取消」的血泪事故。
4. 后台管理与权限:四张表撑起运营侧闭环
4.1 RBAC:四张表加一个拦截器,够用且讲得清
后台权限不需要做得复杂,但也别用「一个 admin 表加个 type 字段」糊弄过去,答辩时老师一问「运营能上架商品但不能删用户,怎么实现」就答不上来。标准 RBAC 四张表:sys_admin管理员、sys_role角色、sys_admin_role关联、sys_menu菜单与权限标识。
sys_menu是关键,每一行代表一个可访问的资源,字段包括perm_code(如product:add)、path、type(菜单还是按钮)。角色通过role_menu关联到多个菜单,管理员再关联角色。登录时把该管理员所有perm_code查出来放进 token 或缓存。
// 权限拦截器:路径匹配 + 权限码校验,白名单直接放行 @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (!(handler instanceof HandlerMethod)) return true; HandlerMethod hm = (HandlerMethod) handler; RequirePerm ann = hm.getMethodAnnotation(RequirePerm.class); if (ann == null) return true; // 没打注解的不校验 String token = request.getHeader("Authorization"); AdminLogin admin = tokenService.parse(token); // 解析失败会抛异常 Set<String> perms = permissionCache.get(admin.getId()); if (!perms.contains(ann.value())) { // 例如 product:delete throw new BizException("没有操作权限"); } return true; }用注解@RequirePerm("product:delete")挂在方法上,比在拦截器里维护一张 URL 白名单表要清晰得多,加接口时顺手加注解,不会漏。要注意的是权限缓存必须在角色变更时主动清除,否则给运营加了权限他还得等缓存过期才能生效,演示时容易翻车。
4.2 商品上架:图片上传与富文本落地
后台商品新增页是整个后台最复杂的一张表单,字段包括名称、分类、主图、规格列表、详情富文本、上下架状态。图片上传走本地磁盘最简单:文件存到D:/mall-upload/之类的目录,数据库只存相对路径/upload/2024/05/xxx.jpg,然后配一个WebMvcConfigurer把/upload/**映射到磁盘目录。
@Configuration public class WebConfig implements WebMvcConfigurer { @Value("${shop.upload.path}") private String uploadPath; // 磁盘绝对路径 @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { // 访问 /upload/a.jpg 实际读取磁盘上的文件,不用走 Controller registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadPath); } }上传接口要做的校验有三条:后缀白名单(jpg、png、webp)、大小限制(配置里那个 5MB)、文件名重命名成 UUID 加后缀。用原始文件名存盘,迟早会遇到重名覆盖和中文乱码。详情富文本直接用longtext存 HTML 原文,前台用v-html渲染,后台提交前过一遍白名单过滤,把script标签清掉,这一步不做,答辩演示时老师往详情里粘一段脚本就能弹窗。
规格列表是动态表单,前端一个「添加规格」按钮追加一行,提交时整体作为一个 JSON 数组传给后端,后端先删掉该商品原有的 SKU 再批量插入。要注意的是已经产生过订单的老 SKU 不能物理删除,改成置为下架状态,否则历史订单的明细会关联到不存在的规格。
4.3 数据看板:三个 SQL 就能撑住首页
后台首页放四张卡片加一张榜单,数据量不大,直接实时查库就行,不用上缓存。今日成交额、今日订单数、待发货数、会员总数,再加一个热销商品 Top 5。
-- 今日成交额:只统计已支付及以后的订单,取消的不算 SELECT IFNULL(SUM(pay_amount), 0) AS amount FROM oms_order WHERE status IN (1, 2, 3) AND pay_time >= CURDATE() AND deleted = 0; -- 热销 Top5:按下单件数聚合,关联商品名便于展示 SELECT i.product_name, SUM(i.quantity) AS sold FROM oms_order_item i JOIN oms_order o ON o.id = i.order_id WHERE o.status IN (1, 2, 3) GROUP BY i.product_name ORDER BY sold DESC LIMIT 5;这两个 SQL 有个共同点:status IN (1,2,3)这个条件必须带上。只查status = 3(已完成)会把当天刚付款还没收货的订单漏掉,看板数字永远偏低;不限制状态则会把已取消的订单算进成交额,数字虚高。数据看板是演示时最先被看到的东西,数字对不上,后面讲得再好也会被追问。
5. 避坑与排查:六个最容易翻车的点
5.1 库存超卖:压测时才发现少卖了十几件
现象是并发下单接口测下来,库存 10 件卖出去了 15 单,但库存字段显示还剩 2。原因是先查后改的写法在并发下必然出问题,两个线程读到同一个值,都判断通过。解决办法是把判断和更新合成一条update ... where stock >= n,用影响行数判断成败。写完记得用 JMeter 或 ab 起 50 个并发打一遍下单接口验证,不测就等于没改。
5.2 金额用 double:对不上账的两分钱
现象是订单明细求和跟订单总额差几分钱。原因是double在二进制里表示不了 0.1 这类小数,累加十几次误差就出来了。解决办法是数据库统一decimal(10,2),Java 统一BigDecimal,并且所有比较用compareTo而不是equals——new BigDecimal("1.0").equals(new BigDecimal("1.00"))返回 false,这个坑足够让人查一晚上。
5.3 订单重复提交:用户手快点了三下
现象是同一个用户同一批商品生成了三张待付款订单。原因有两个,前端按钮没做防抖,后端接口没有幂等控制。解决办法分两层:前端点击后立即置灰按钮并在请求结束前不恢复;后端在提交页生成一个一次性submitToken存进缓存,下单接口第一件事就是校验并删除这个 token,删不到直接返回「请勿重复提交」。
5.4 事务失效:抛了异常但数据没回滚
现象是下单时库存扣了、订单没生成,数据卡在中间状态。原因通常有三种:同一个类里 A 方法直接调 B 方法,B 上的@Transactional不走代理所以无效;异常被 try-catch 吞掉了没有往外抛;抛的是受检异常而注解没写rollbackFor。解决办法是事务方法统一写在 Service 的 public 方法上、需要内部调用就注入自身代理或用TransactionTemplate、注解一律补上rollbackFor = Exception.class。
5.5 分页总数不对:一对多关联把条数算多
现象是订单列表每页显示 10 条,总条数却显示 47,翻到最后一页全是重复数据。原因是分页 SQL 里 join 了订单明细表,一张订单对应三条明细就被算成三条。解决办法是主查询只查订单主表,明细用IN二次查询再在内存里拼装;如果一定要 join,count 语句要写成count(distinct o.id)。
5.6 图片上传后不显示:路径对了但 404
现象是上传接口返回成功,数据库里也有路径,页面就是显示不出来。原因多半是资源映射写成了classpath:或者路径少了file:前缀,Spring 会去 jar 包里找文件。解决办法是addResourceLocations("file:" + 绝对路径 + "/"),结尾斜杠不能少,并在启动日志里确认映射是否生效。
6. 验收与答辩:把系统讲成一条能自证的闭环
验收这件事,我一般建议先做一张清单,自己照着走一遍,走不通的地方就是答辩会被问住的地方。
| 验收项 | 操作动作 | 通过标准 |
|---|---|---|
| 商品链路 | 后台新增商品含 2 个规格并上架 | 前台列表可见、详情能切换规格、价格随规格变化 |
| 购物车合并 | 游客加购 2 件后登录 | 购物车数量累加,超 99 截断,失效商品被剔除 |
| 下单事务 | 库存只有 1 时并发下 2 单 | 一单成功一单提示库存不足,库存不为负 |
| 状态流转 | 下单后不付款等 30 分钟 | 订单自动取消,库存回到扣减前 |
| 权限控制 | 用运营账号删商品 | 接口返回无权限,按钮不可见 |
| 金额一致性 | 明细求和与订单总额比对 | 分毫不差,历史订单不受商品改价影响 |
演示脚本控制在 8 分钟:先开前台,浏览分类、搜商品、切规格、加购两件、去结算、下单、然后跳后台用管理员账号发货、切回前台确认收货,最后打开看板看今日成交额有没有变。这条主线走完,整个系统的价值就自证了,不需要额外解释。
论文的章节和系统的模块要对得上。第 3 章需求分析对应前台那几个功能点,第 4 章总体设计对应架构图和那张十二张表的 E-R 图,第 5 章详细设计对应下单事务、库存扣减、状态机这三段,第 6 章测试对应上面那张验收清单。很多人的论文和代码是两张皮,一问「你论文里写的缓存策略在哪」就答不上来,对不上的部分宁可删掉。
答辩被问得最多的三个问题,提前准备好答案:库存怎么防超卖(条件更新加影响行数判断,可以现场跑并发看结果)、订单状态怎么保证不乱(枚举加流转校验,取消和完成是终态)、权限怎么做到按钮级(权限码存 token,拦截器加注解)。这三个答得出来,剩下的都是细节。
我最早带人做这类系统时,总觉得功能写完就等于做完了,直到有一次演示现场点下单,页面转圈十秒然后报错,原因是库存扣到负数触发了数据库约束。从那以后我每次交付前都强制走一遍上面那条八分钟主线,从游客浏览一路走到后台发货,一步不跳。这套流程帮我挡掉过至少三次当场翻车。希望帮到你。
本文还有配套的精品资源,点击获取