
简介面向计算机专业学生、软件工程课程设计及毕业设计参考场景这份网上购物系统资料包将论文文档与项目源码一并提供帮助读者理解电商系统从需求分析、功能规划、架构设计到编码实现的完整链路。压缩包共2000个文件、约200.04MB包含大量js、css、html、aspx页面与脚本cs、sln、csproj等C#项目工程文件以及docx论文、mdb数据库、config配置和gif、png、jpg图片资源前后端代码、数据库与文档层次较完整便于按模块检索。已有81人学习具备一定参考热度。读者可重点查看商品浏览、购物车、订单处理、用户管理、支付对接和后台管理等模块的实现方式并结合论文中的需求分析、数据库设计、登录验证、SQL注入与XSS防护、系统测试等内容形成从设计到落地的完整认识适合作为课程设计模板、毕业设计参考资料或二次开发底稿。1. 需求切入网上购物系统真正难的不是页面而是订单很多同学接到“基于web的网上购物系统的设计与实现”这个题目第一反应是把首页、商品列表、详情页先画出来觉得页面像那么回事系统就完成大半。真到答辩现场被问得最多、也最容易翻车的恰恰是加购之后到订单生成这一段库存怎么扣、并发下单会不会超卖、订单状态怎么流转、后台改价后购物车里的旧价格怎么办。所以这套方案从第一行代码起就把重心放在业务链路上页面只做承载。它适合两类人一类是在做课程设计或毕业设计、需要一套能跑通「浏览—加购—下单—后台管理」完整闭环的同学另一类是手上有个类似需求、想拿一份结构清楚、分层规矩、能直接改成自己项目的从业者。下面按需求、选型、总体设计、核心链路、避坑、验收六段展开每段都落到能复现的细节。2. 需求拆解与技术选型三类角色、两套界面、一个数据源2.1 角色与用例边界购物系统的需求看似庞大收敛下来其实就三类角色、十几个用例。先把边界划清楚后面写代码才不会一边写一边改表结构。游客浏览首页、按分类筛选、关键词搜索、查看商品详情、注册、登录。游客不写库所有行为都是只读的。注册用户在游客能力之上增加加入购物车、修改数量、删除条目、填写收货地址、提交订单、查看自己的订单列表与详情、取消未付款订单。管理员商品的新增、编辑、上下架、库存调整分类的增删改订单的查询与发货用户列表查看以及一个简单的销售统计。把这三类角色写进一张用例对照表答辩时可以直接指着讲比口头描述清楚得多。功能游客注册用户管理员商品浏览与搜索支持支持支持加入购物车临时会话持久数据库不支持提交订单不支持支持不支持商品上下架不支持不支持支持订单发货不支持不支持支持这里有个设计决策值得单独说游客的购物车到底存哪儿。如果游客不能加购转化路径就断了如果游客加购也直接写库就得先有用户身份。通常的做法是游客阶段把「商品 ID 数量」放在会话里登录成功的一瞬间做一次合并把会话里的条目并入数据库购物车同商品数量相加。这一步在 4.2 节会给出具体代码。2.2 技术栈选型为什么是 Spring Boot MyBatis MySQL题目是「基于 web」可选的技术路线很多。纯 Servlet JSP 能跑但每一层都要手写代码量大、分层讲不清楚SSM 是经典组合配置偏多答辩时容易被追问「这么多 XML 是干什么的」Spring Boot 把容器、数据源、事务、静态资源都收进 starter一个主类就能启动分层反而更显眼。最终选型后端 Spring Boot Spring MVC MyBatis数据库 MySQL 8.0模板引擎 Thymeleaf前端 Bootstrap 原生 JavaScript构建工具 Maven内嵌 Tomcat 直接java -jar启动。选 Thymeleaf 而不是 JSP是因为 JSP 在现代容器里对打包方式的兼容性越来越别扭Thymeleaf 的模板就是普通 HTML改完刷新即生效调试成本低。核心配置集中在application.yml把几个关键参数的含义讲清楚比贴一堆依赖更有价值。server: port: 8080 servlet: session: timeout: 30m # 会话 30 分钟过期太短用户结账时掉线太长内存占用高 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/shop?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/Shanghai username: shop password: shop123 driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 # 毕设演示环境 10 个连接足够调大反而抢不到资源 thymeleaf: cache: false # 开发期关闭模板缓存改完 HTML 不用重启 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.shop.entity configuration: map-underscore-to-camel-case: true # 数据库 create_time 自动映射成 createTime几个参数值得展开。characterEncodingutf8mb4而不是utf8是为了让商品名里的表情符号和生僻字能正常入库serverTimezone必须显式指定否则 MySQL 8 驱动在部分环境下会报时区错误这是新手最常踩的一个坑。map-underscore-to-camel-case打开之后实体类不用再逐个字段写Results少写几十行映射。连接池上限设成 10 而不是默认值是因为答辩演示机和本地开发机往往是同一台机器池子开太大压测时反而互相拖累。2.3 非功能性需求的最低线课程设计也要写非功能性需求但别写成空话。落成可验证的条目才有意义页面响应商品列表在 500 条数据量下服务端渲染时间控制在 300 毫秒以内并发单机 50 并发下单不出现库存为负这一点在 4.3 和 5.1 会重点讲安全用户密码加盐哈希入库登录态用会话拦截器统一校验所有 SQL 走预编译参数兼容Chrome、Edge 主流版本下布局不错位不做 IE 适配可维护Controller 不写业务逻辑Service 不写 SQLMapper 只做数据访问。这五条写进文档的「需求分析」章节验收时逐条对照比写「系统性能良好」有说服力得多。3. 总体设计三层结构、十个模块与七张核心表3.1 分层结构Controller-Service-DAO 各管一段整个系统是标准的单体三层结构请求从浏览器进来走过滤器、拦截器、Controller、Service、Mapper最后落到 MySQL再把结果顺着原路返回。Controller 层只做三件事接收参数、做基础校验、调用 Service 并把结果塞进 Model 返回视图。任何一段超过 30 行的 Controller 方法基本都能判定为职责越界。Service 层承载业务规则库存够不够、订单状态能不能从这个值跳到那个值、购物车合并时数量怎么相加。事务注解也打在这一层因为一次下单涉及扣库存、写主单、写明细、清购物车四个动作必须同生共死。Mapper 层只写 SQL不判空、不组装业务对象。条件更新的语义比如「库存大于等于购买量才扣」写在 SQL 里比在 Java 里先查后改更可靠。登录鉴权用拦截器统一处理。配置拦截/user/**和/order/**放行/login、/register、/product/**和静态资源。拦截器里从会话取用户对象取不到就重定向到登录页并把原始地址带上登录成功后跳回去。这套逻辑写一次后面所有需要登录的接口都不用重复判断。3.2 模块划分前台六个、后台四个前台模块用户模块注册、登录、资料、地址、商品模块列表、搜索、详情、分类模块、购物车模块、订单模块提交、列表、详情、取消、评价模块可选作为加分项。后台模块管理员登录与鉴权、商品与分类管理、订单管理、用户与统计。模块之间的依赖尽量单向订单依赖商品和购物车购物车依赖商品商品依赖分类。不做反向依赖避免出现「删分类要顺手删商品」这种连环操作。3.3 数据库设计七张核心表与字段取舍表设计是这个项目里最该多花时间的地方。字段少一个后面改起来要动实体、Mapper、模板三层。核心七张表user、category、product、cart_item、orders、order_item、address管理员表可以并进user用角色字段区分也可以单独建这里选择用角色字段区分少一张表少一套登录逻辑。商品表CREATE TABLE product ( id bigint NOT NULL AUTO_INCREMENT COMMENT 商品主键, category_id bigint NOT NULL COMMENT 所属分类, name varchar(120) NOT NULL COMMENT 商品名称, price decimal(10,2) NOT NULL COMMENT 售价单位元, stock int NOT NULL DEFAULT 0 COMMENT 可用库存, cover varchar(255) DEFAULT NULL COMMENT 封面图相对路径, status tinyint NOT NULL DEFAULT 1 COMMENT 1上架 0下架, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_category_status (category_id,status), KEY idx_name (name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品表;三个细节金额用decimal(10,2)而不是float浮点数做加法会出现 0.10.2 这种经典误差订单金额对不上账库存单独一个int字段并且允许条件更新是防超卖的基础idx_category_status这个联合索引对应「某分类下的上架商品」这个最高频查询把分类放左边是因为它的区分度低于状态但过滤掉的数据比例更高。订单主表与明细表CREATE TABLE orders ( 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 订单总金额, status tinyint NOT NULL DEFAULT 0 COMMENT 0待付款 1已付款 2已发货 3已完成 4已取消, receiver varchar(32) NOT NULL COMMENT 收货人快照, phone varchar(20) NOT NULL, address varchar(255) NOT NULL, 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_status (user_id,status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单主表; CREATE TABLE order_item ( id bigint NOT NULL AUTO_INCREMENT, order_id bigint NOT NULL, product_id bigint NOT NULL, product_name varchar(120) NOT NULL COMMENT 下单时的商品名快照, price decimal(10,2) NOT NULL COMMENT 下单时的单价快照, quantity int NOT NULL, PRIMARY KEY (id), KEY idx_order_id (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单明细表;注意order_item里的product_name和price它们不是冗余是快照。商品后台改价、改名、下架之后历史订单必须显示下单当时的信息。这一点答辩时被问到的概率极高能答上来是加分项。收货地址同样在订单上做快照而不是只存address_id因为用户删地址是常见操作删完之后订单不能变成空地址。购物车表的关键是一个唯一键CREATE TABLE cart_item ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL, product_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_product (user_id,product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT购物车表;uk_user_product保证同一用户同一商品只有一行重复加购走「存在则数量相加」的写法后面 4.2 节会用到。4. 核心链路实现从商品检索到订单落库4.1 商品列表分页、条件检索与索引配合商品列表是访问量最大的页面也是索引有没有建对的试金石。Controller 只负责接收分页参数和筛选条件GetMapping(/product/list) public String list(RequestParam(defaultValue 1) Integer page, RequestParam(defaultValue 12) Integer size, RequestParam(required false) Long categoryId, RequestParam(required false) String keyword, Model model) { // 分页交给 PageHelper它会在下一条 SQL 上自动追加 limit PageHelper.startPage(page, size); ListProduct list productMapper.selectByCondition(categoryId, keyword); PageInfoProduct pageInfo new PageInfo(list); model.addAttribute(pageInfo, pageInfo); model.addAttribute(categoryId, categoryId); model.addAttribute(keyword, keyword); return product/list; }对应的 Mapper XML 用动态 SQL 拼条件注意status 1是硬条件下架商品永远不出现在前台select idselectByCondition resultTypecom.example.shop.entity.Product SELECT id, category_id, name, price, stock, cover, status, create_time FROM product WHERE status 1 if testcategoryId ! null AND category_id #{categoryId} /if if testkeyword ! null and keyword ! AND name LIKE CONCAT(%, #{keyword}, %) /if ORDER BY id DESC /select参数说明page从 1 开始size默认 12三列布局一行四个正好三行categoryId为空表示全部keyword用LIKE前后模糊。这里要坦白一句LIKE %关键词%用不上idx_name索引数据量上万之后会明显变慢。课程设计的数据量不必上全文索引但如果想加分可以在文档里写明「后续可替换为 MySQL 全文索引或独立搜索服务」一句话就把技术视野摆出来了。分页还有一个容易忽略的点翻页时筛选条件要跟着走。模板里的翻页链接必须同时带上categoryId和keyword否则用户搜完关键词翻到第二页条件就丢了看到的变成全量商品。这是纯前端细节但答辩演示时一旦出现观感很差。4.2 购物车游客走会话登录走数据库购物车的存储方案有两套各有各的适用面。全放会话实现快不用建表但用户换设备就丢会话过期也丢。全放数据库持久、可跨端但游客没有身份。折中方案是两段式——未登录放会话登录瞬间合并进数据库。游客加购用一个MapLong, Integer挂在会话上键是商品 ID值是数量。登录成功的回调里做合并// 登录成功后调用把会话里的游客购物车并入数据库同商品数量相加 public void mergeGuestCart(HttpSession session, Long userId) { MapLong, Integer guest (MapLong, Integer) session.getAttribute(GUEST_CART); if (guest null || guest.isEmpty()) { return; } for (Map.EntryLong, Integer entry : guest.entrySet()) { // 依赖 uk_user_product 唯一键存在则累加不存在则插入 cartMapper.insertOrAdd(userId, entry.getKey(), entry.getValue()); } session.removeAttribute(GUEST_CART); }insertOrAdd用一条 SQL 搞定「有则加、无则插」避免先查后插的并发问题insert idinsertOrAdd INSERT INTO cart_item (user_id, product_id, quantity) VALUES (#{userId}, #{productId}, #{quantity}) ON DUPLICATE KEY UPDATE quantity quantity VALUES(quantity) /insertON DUPLICATE KEY UPDATE依赖前面建的唯一键uk_user_product没有这个键语句会一直插入新行。这是 MySQL 特有的写法换成其他数据库要改成MERGE或先更新后插入的两段式。加购时还要做一次库存校验想加 5 件但库存只有 3 件要么提示要么按 3 件加不要默默写进去等到下单才报错用户会觉得系统在甩锅。购物车列表页的金额计算放在 Service 层做不要在模板里用表达式乘。原因很实际模板里算出来的金额没有统一的舍入规则和订单落库时的金额可能差几分钱。正确做法是服务端算好每一项小计和总计塞进模型模板只负责显示。4.3 下单流程事务边界、条件扣库存与订单号下单是整个系统的心脏也是答辩追问的集中区。一次提交要完成四件事校验购物车、扣库存、写订单主表与明细、清空已下单的购物车条目。四件事必须在同一个事务里。Service public class OrderServiceImpl implements OrderService { Resource private ProductMapper productMapper; Resource private OrderMapper orderMapper; Resource private OrderItemMapper orderItemMapper; Resource private CartMapper cartMapper; // rollbackFor 显式指定 Exception避免受检异常不回滚 Override Transactional(rollbackFor Exception.class) public String submit(Long userId, ListLong cartIds, Address addr) { ListCartItem items cartMapper.selectByIds(userId, cartIds); if (items.isEmpty()) { throw new BizException(购物车为空无法下单); } BigDecimal total BigDecimal.ZERO; for (CartItem item : items) { // 条件更新库存不足时影响行数为 0直接抛异常触发整体回滚 int affected productMapper.deductStock(item.getProductId(), item.getQuantity()); if (affected 0) { throw new BizException(商品[ item.getProductId() ]库存不足); } total total.add(item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); } String orderNo OrderNoGenerator.next(userId); Orders order new Orders(orderNo, userId, total, addr); orderMapper.insert(order); for (CartItem item : items) { // 明细写入名称与单价快照商品后续改价不影响历史订单 orderItemMapper.insert(order.getId(), item); } cartMapper.deleteByIds(userId, cartIds); return orderNo; } }扣库存的 SQL 是防超卖的关键注意条件写在WHERE里而不是先查后改update iddeductStock UPDATE product SET stock stock - #{quantity} WHERE id #{productId} AND stock gt; #{quantity} AND status 1 /updateAND stock #{quantity}这个条件的意义在于数据库在执行更新时会加行锁判断和扣减在同一个原子操作里完成。两个请求同时进来第二个会等第一个提交后再判断库存不够就影响 0 行。如果写成「先SELECT stock判断够不够再UPDATE stock stock - n」两个请求可能都读到够然后都扣库存就成负数了。这是这个项目里最值得在答辩时主动讲的一段。订单号生成没有引入分布式 ID 组件单机环境用时间戳加用户尾号加自增就够了public final class OrderNoGenerator { private static final AtomicInteger SEQ new AtomicInteger(0); public static String next(Long userId) { // 时间戳(14) 用户尾号(3) 单机自增(3)共 20 位可读且能大致看出下单时间 return LocalDateTime.now().format(DateTimeFormatter.ofPattern(yyyyMMddHHmmss)) String.format(%03d, userId % 1000) String.format(%03d, SEQ.getAndIncrement() % 1000); } }自增取模 1000 意味着同一秒同一用户下单超过 1000 次会重复实际场景不可能发生。如果项目要往集群方向讲就明说「集群环境需要换成雪花算法或号段模式」不硬撑。4.4 订单状态机把「能不能改」写成一张表订单状态最怕乱跳已发货的订单被改成待付款已取消的订单又被发货。解决办法是把合法流转写成一张静态表所有状态变更统一走一个方法。当前状态允许流转到触发动作0 待付款1 已付款、4 已取消支付成功、用户取消1 已付款2 已发货、4 已取消管理员发货、退款取消2 已发货3 已完成用户确认收货3 已完成无终态4 已取消无终态private static final MapInteger, ListInteger ALLOW new HashMap(); static { ALLOW.put(0, Arrays.asList(1, 4)); ALLOW.put(1, Arrays.asList(2, 4)); ALLOW.put(2, Arrays.asList(3)); ALLOW.put(3, Collections.emptyList()); ALLOW.put(4, Collections.emptyList()); } public void changeStatus(Long orderId, int target) { Orders order orderMapper.selectById(orderId); if (order null) { throw new BizException(订单不存在); } if (!ALLOW.getOrDefault(order.getStatus(), Collections.emptyList()).contains(target)) { throw new BizException(订单状态不允许从 order.getStatus() 变更为 target); } orderMapper.updateStatus(orderId, target); }这段代码不长但它把「业务规则」和「数据更新」分开了规则在内存里判断更新只负责写。后面要加退款、加售后只需要往ALLOW里加一行不用改调用方。后台的发货按钮只在状态为 1 时显示前端拦一道后端再拦一道双保险。5. 避坑与排查库存、价格、会话、状态、编码这五件事5.1 库存扣成负数现象压测或者手动开两个浏览器同时下单最后商品库存显示 -3。原因扣库存写成了「先查再改」两条语句之间存在时间窗口两个请求读到同一个库存值都判断为够于是都执行扣减。解决改成 4.3 节那种带条件的单条UPDATE ... WHERE stock n用影响行数判断成败为 0 就抛异常回滚。另外在product表的stock字段上加一个CHECK (stock 0)约束作为兜底虽然 MySQL 8 之前对 CHECK 支持不完整但写上不亏。5.2 购物车价格和下单价格对不上现象用户加购时商品 99 元管理员改成 89 元用户购物车里还是 99下单时算出来的总价却是 89两边对不上。原因购物车表里存了价格下单时又去商品表重新取了一次价格两个数据源。解决二选一不要混。这里的做法是购物车表不存价格每次渲染购物车列表时实时关联商品表取当前价页面上显示「价格以下单时为准」下单时再取一次价格写入明细快照。这样购物车永远显示最新价历史订单永远保留下单价两个诉求都满足。5.3 登录后刷新就掉线现象登录成功跳转正常一按 F5 就回到登录页。原因多半是拦截器把静态资源也拦了或者会话 Cookie 的路径配置不对还有一种情况是用了前后端分离的写法但没处理跨域凭证。解决拦截器配置里明确excludePathPatterns(/css/**, /js/**, /images/**, /login, /register)服务端渲染的项目不要动 Cookie 的 path如果确实做了跨域前端请求要带withCredentials后端要允许具体源而不是*。排查时打开浏览器开发者工具看请求头里有没有JSESSIONID一步定位。5.4 订单重复提交现象用户下单按钮点快了两次生成两笔一模一样的订单库存扣了两次。原因表单没有防重后端也没有幂等控制。解决前端在提交后立刻禁用按钮并显示「提交中」后端在下单接口上加一个基于用户和购物车条目的短期锁比如会话里放一个正在处理的标记或者在orders表上加一个由「用户 ID 购物车条目哈希」组成的唯一键重复插入直接失败。课程设计用前者就够后者更适合往深处讲。5.5 中文乱码和图片路径丢失现象商品名在数据库里是正常中文页面上显示问号上传的图片在本地能看到重新部署后全变成裂图。原因乱码通常是连接串少了characterEncoding或者建表时字符集用了utf8而字段里存了四字节字符图片丢失是把文件存到了项目目录下的临时路径重新打包部署就被覆盖。解决连接串统一utf8mb4建表语句显式DEFAULT CHARSETutf8mb4上传文件存到项目目录之外的固定路径数据库里只存相对路径模板渲染时拼接访问前缀。这样部署换机器只要改一个配置项不用动数据。6. 验收与答辩一套能照着走完的检查清单验收这件事最忌讳临场想到哪演示到哪。我一般会把演示路径固定成一条主线从游客开始一路走到后台发货全程不切换角色之外的任何操作。下面这张清单可以直接拿去对照。序号检查项操作路径预期结果1游客浏览首页进入商品列表切换分类列表按分类过滤分页正常2搜索输入关键词回车结果命中翻页保留关键词3游客加购未登录点加入购物车提示已加入会话内有记录4注册登录注册新账号并登录游客购物车自动合并进账号5下单选地址提交订单生成订单号库存减少购物车清空6库存校验把库存改成 1下单 2 件提示库存不足库存仍为 17后台发货管理员登录订单列表点发货状态从已付款变已发货8状态拦截对已发货订单点取消提示状态不允许数据不变演示时把第 6 项放在显眼位置主动演一遍比等老师提问再解释强得多。它证明的不是「我的系统有 bug」而是「我想到了并发场景并且做了防护」这是两回事。答辩追问高频集中在三个方向提前准备好答案为什么金额用 decimal 不用 double答浮点精度误差会导致订单金额和明细合计对不上用定点数从存储层就规避。订单表为什么要存商品名和单价答快照商品改价改名不能影响历史订单同时减少订单详情页的关联查询。库存并发怎么保证答条件更新的原子性配合影响行数判断和事务回滚必要时补充说明数据库行锁的作用范围。还有一个容易被忽略的加分项把「没做什么」讲清楚。比如支付是模拟的、没有接真实支付渠道搜索用的是LIKE模糊匹配、数据量大之后需要换方案评价模块只做了单向展示、没有做追评。主动划出边界比被问出来再解释要从容得多。从那以后我每次带人做这类系统都会要求对方在动手写代码前先把这张验收表填一遍填不出来的地方就是需求没想清楚的地方。这套流程走下来答辩时被问住的概率会低很多。希望帮到你。本文还有配套的精品资源点击获取