简介:一份完整的基于Java Web的鲜花商城系统设计与实现毕业设计文档,主要面向计算机相关专业学生、毕业设计撰写者以及Java Web初学者。文档以鲜花商城为业务场景,系统讲解B/S开发模式、Java语言编码、JSP动态界面设计、MySQL数据库管理、JDBC数据库交互及Tomcat部署等内容,详细介绍商品浏览、购物车管理、在线支付、订单处理、用户管理和评论互动等核心模块,并给出数据表结构设计思路,涵盖需求分析、可行性分析、技术支持、功能设计、数据库设计、功能测试等完整流程,配有主要功能模块界面与操作界面说明,适合用于课程设计、毕业设计或项目实战参考。资源包为1个docx文档,整体大小2.75MB,排版完整,便于查阅与二次编辑。目前已有72人学习下载,适合希望快速掌握校园商城项目设计思路与文档撰写规范的学习者。
1. 拿 Java 做鲜花商城系统,从哪里入手最稳
很多计算机专业的朋友拿到“基于 Java 的鲜花商城系统设计与实现”这个课题,第一反应是到处找完整源码。但真正到答辩或者作品展示时,被问得最多的往往是“库存怎么扣的”“购物车价格怎么算的”“订单状态怎么流转的”。与其抄一套看不懂的代码,不如用 Spring Boot + MyBatis-Plus + MySQL 这套主流组合,把用户、商品、购物车、订单、分类这五条线自己搭一遍,两周时间足够做到能演示、能讲原理。这篇博文按“技术选型 → 数据库设计 → 后端核心链路 → 前端数据渲染 → 验证技巧”的顺序展开,所有命令和代码都按可复现的标准写。适合正在做课设、想写进简历的 Java 初学者,也适合想快速补一套商城系统代码结构的面试准备者。
2. 鲜花商城系统的技术选型和工程结构,先用 Spring Boot 固定骨架
2.1 为什么是 Spring Boot 而不是 JSP + Servlet 或 SSM
以前教材里常见的是 JSP + Servlet + JDBC,或者 SSM(Spring + Spring MVC + MyBatis)手写大量 XML 配置。现在做鲜花商城系统,最省事也最容易讲清楚的是 Spring Boot。它内置 Tomcat,不用额外部署 war 包;自动配置把数据源、事务、JSON 序列化这些琐事处理掉,让你把精力集中在业务逻辑上。
我一般建议用 Spring Boot 2.7.x 搭配 JDK 8 或 JDK 11,原因有两个。第一,网上绝大多数资料和面试题都基于这套版本,遇到报错容易搜到答案;第二,Spring Boot 3.x 要求 JDK 17,部分学校机房的环境不一定支持。MyBatis-Plus 选 3.5.x,它对单表 CRUD 的支持很省事,分页插件也好配。数据库用 MySQL 5.7 或 8.0 都行,字符集统一 utf8mb4。前端模板引擎用 Thymeleaf,不用前后端分离,因为课设系统通常要演示“页面跳转 + 表单提交 + 数据回显”,Thymeleaf 一套就能完成,不需要再搭 Node 环境。
提示:如果导师明确要求“必须体现 Servlet 或 JSP”,那可以在 Spring Boot 里引入 JSP 支持,但核心业务逻辑仍放在 Service 层,这样两头都占住。
2.2 包结构划分:用 controller / service / mapper 三层把职责分开
鲜花商城系统至少包含用户登录注册、商品列表、商品详情、购物车、订单结算这几个模块。代码包结构我会这样设计,每个包只负责一件事,答辩时也方便按包讲解:
com.example.flower ├── FlowerApplication.java // 启动类 ├── config │ └── MybatisPlusConfig.java // MyBatis-Plus 分页插件配置 ├── controller │ ├── UserController.java // 登录、注册、退出 │ ├── GoodsController.java // 商品列表、详情、分类筛选 │ ├── CartController.java // 购物车增删改查 │ └── OrderController.java // 下单、订单列表、取消 ├── service │ ├── GoodsService.java │ ├── CartService.java │ ├── OrderService.java │ └── impl │ ├── GoodsServiceImpl.java │ ├── CartServiceImpl.java │ └── OrderServiceImpl.java ├── mapper │ ├── GoodsMapper.java │ ├── CartMapper.java │ ├── OrderMapper.java │ └── UserMapper.java ├── entity │ ├── User.java │ ├── Goods.java │ ├── Cart.java │ ├── Order.java │ └── OrderItem.java ├── dto │ └── CartVO.java // 购物车展示对象,含商品名、单价、小计 └── common └── Result.java // 统一返回结构controller 只接收参数和返回结果,不写业务代码;service 处理事务和业务校验;mapper 只做数据库操作。这个分层是“Java 后端完整成长路线”里反复强调的规范,对 5 年以上经验的开发者来说可能觉得基础,但对课设和简历项目来说,能讲清分层本身就是加分项。
2.3 最小可运行配置:pom.xml 依赖和 application.yml 参数
先建一个 Spring Boot 项目,核心依赖就四个:web、thymeleaf、mybatis-plus-boot-starter、mysql-connector-java。lombok 建议加上,减少实体类 getter/setter 代码。下面是 pom.xml 里最关键的片段:
<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> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency>对应的 application.yml:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/flower_shop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 thymeleaf: cache: false mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0配置里有两个容易踩的坑。第一,url 必须带serverTimezone=Asia/Shanghai,否则 MySQL 8.0 会报时区错误。第二,thymeleaf.cache=false一定要开,开发时改完页面刷新就能看到效果,不用重启。log-impl配置成 StdOutImpl 后,控制台会打印每条 SQL,方便调试参数传递。
配好后运行mvn spring-boot:run或直接启动主类,浏览器访问http://localhost:8080能看到默认错误页,说明项目骨架没问题。
3. 鲜花商城系统的核心表设计:六张表把业务边界划清楚
3.1 用户、商品、分类、购物车、订单、订单明细的建表 SQL
鲜花商城系统不建议建太多表,六张足够覆盖需求。重点在字段类型和索引设计。商品表的价格用decimal(10,2),数量用int,状态用tinyint。订单表要单独存一份商品快照信息,因为用户下单后商品可能改价或下架。下面是建表 SQL:
CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录名', `password` varchar(100) NOT NULL COMMENT 'MD5或BCrypt', `nickname` varchar(50) DEFAULT NULL, `phone` varchar(20) DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `goods_category` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL, `sort` int(11) DEFAULT 0 COMMENT '排序值,越小越靠前', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `goods` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `category_id` bigint(20) NOT NULL, `name` varchar(100) NOT NULL COMMENT '商品名称', `subtitle` varchar(200) DEFAULT NULL COMMENT '卖点描述', `main_image` varchar(255) DEFAULT NULL COMMENT '图片相对路径', `price` decimal(10,2) NOT NULL, `stock` int(11) NOT NULL DEFAULT 0, `sales` int(11) NOT NULL DEFAULT 0 COMMENT '销量', `status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '1上架 0下架', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category_status` (`category_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `cart` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL, `goods_id` bigint(20) NOT NULL, `quantity` int(11) NOT NULL DEFAULT 1, `checked` tinyint(4) NOT NULL DEFAULT 1 COMMENT '是否勾选结算', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_goods` (`user_id`, `goods_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `order` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '业务订单号', `user_id` bigint(20) NOT NULL, `total_amount` decimal(10,2) NOT NULL COMMENT '订单总金额', `status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已发货 3已完成 4已取消', `receiver_name` varchar(50) NOT NULL, `receiver_phone` varchar(20) NOT NULL, `receiver_address` varchar(200) NOT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_status` (`user_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `order_item` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_id` bigint(20) NOT NULL, `goods_id` bigint(20) NOT NULL, `goods_name` varchar(100) NOT NULL COMMENT '商品名称快照', `goods_image` varchar(255) DEFAULT NULL, `price` decimal(10,2) NOT NULL COMMENT '成交单价快照', `quantity` int(11) NOT NULL, PRIMARY KEY (`id`), KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;商品表上建了idx_category_status联合索引,因为商城首页最常见的查询是“某个分类下查上架商品”,这个索引能直接覆盖查询条件。购物车表用联合唯一键uk_user_goods防止同一用户把同一商品加两遍,代码里要做INSERT ... ON DUPLICATE KEY UPDATE quantity = quantity + 1或先查后插。
3.2 使用 MyBatis-Plus 分页查询商品列表并支持分类和关键字筛选
MyBatis-Plus 的分页功能需要先配置分页插件,不然Page对象查出来的记录数永远是 0。配置类如下:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(100L); interceptor.addInnerInterceptor(pagination); return interceptor; } }setMaxLimit(100L)是保护性参数,防止有人传一个超大的 pageSize 把数据库打慢。查询服务层写法:
@Override public Page<Goods> pageGoods(long pageNum, long pageSize, Long categoryId, String keyword) { Page<Goods> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Goods> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Goods::getStatus, 1) .eq(categoryId != null, Goods::getCategoryId, categoryId) .like(StringUtils.hasText(keyword), Goods::getName, keyword) .orderByDesc(Goods::getSales) .orderByDesc(Goods::getCreateTime); return goodsMapper.selectPage(page, wrapper); }eq(condition, column, value)是 MyBatis-Plus 条件构造器的固定写法,第一个参数为 true 时才会拼接这段 SQL。这种写法避免了手写动态 SQL 时那些<if>标签,但要提醒一点:条件构造器适合单表简单查询,多表 join 还是要写在 XML 里。商品表和分类表 join 的场景不多,用两次查询或直接在 SQL 里 LEFT JOIN 都可以,我更推荐后者,因为一次 IO 比两次快。
3.3 下单时的数据一致性:事务、库存扣减和订单状态
商城系统里最容易出问题的就是“扣库存”和“生成订单”不是同时成功的。Spring 的@Transactional能把这两个操作放在同一个数据库事务里。扣库存的 SQL 要写条件stock >= quantity,这样并发时不会扣成负数,这也是面试里常问的“乐观锁/悲观锁”之外最常见的方案:
@Transactional(rollbackFor = Exception.class) public Long createOrder(OrderCreateDTO dto) { String orderNo = generateOrderNo(); // 1. 锁定购物车中勾选的商品 List<Cart> cartList = cartMapper.selectCheckedList(dto.getUserId()); // 2. 遍历商品,逐个扣库存 for (Cart cart : cartList) { int rows = goodsMapper.deductStock(cart.getGoodsId(), cart.getQuantity()); if (rows == 0) { throw new BusinessException("商品库存不足:" + cart.getGoodsId()); } } // 3. 保存订单和明细 orderMapper.insert(order); orderItemMapper.batchInsert(orderItems); // 4. 清空购物车 cartMapper.deleteByUserAndChecked(dto.getUserId()); return order.getId(); }rollbackFor = Exception.class说明任何异常都回滚事务。deductStock的 XML 写法是重点:
<update id="deductStock"> UPDATE goods SET stock = stock - #{quantity}, sales = sales + #{quantity} WHERE id = #{goodsId} AND stock >= #{quantity} </update>affected rows 为 0 就代表库存不足,抛出异常让整个事务回滚。这里不要把“先查库存再更新”写成两条 SQL,因为查和改之间存在时间差,并发下单时会超卖。
4. 鲜花商城系统的后端业务链路:从登录到购物车再到订单
4.1 登录状态用 Session + ThreadLocal 保存当前用户
商城系统几乎所有操作都要知道“当前登录用户是谁”。常见做法是登录成功后把用户 ID 放到 Session 里,再通过拦截器把 ID 取出来放进 ThreadLocal。好处是业务代码里不用每个方法都传 userId 参数。
public class UserContext { private static final ThreadLocal<Long> HOLDER = new ThreadLocal<>(); public static void set(Long userId) { HOLDER.set(userId); } public static Long get() { return HOLDER.get(); } public static void clear() { HOLDER.remove(); } } public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); User user = (User) session.getAttribute("loginUser"); if (user == null) { response.sendRedirect("/user/login"); return false; } UserContext.set(user.getId()); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }拦截器要在配置类里注册,并指定拦截路径。UserContext.clear()一定要放在afterCompletion里,防止线程池复用导致下个请求读到上一个用户的 ID。
4.2 购物车接口:加购、改数量、删除和价格重算
购物车表存的是user_id + goods_id + quantity,展示时要把商品表的信息 join 出来。我一般定义一个CartVO来承接展示数据。加购代码:
public void addToCart(Long userId, Long goodsId, Integer quantity) { if (quantity == null || quantity <= 0) { throw new BusinessException("数量不合法"); } Goods goods = goodsMapper.selectById(goodsId); if (goods == null || goods.getStatus() != 1) { throw new BusinessException("商品不存在或已下架"); } Cart cart = cartMapper.selectByUserIdAndGoodsId(userId, goodsId); if (cart == null) { Cart newCart = new Cart(); newCart.setUserId(userId); newCart.setGoodsId(goodsId); newCart.setQuantity(quantity); cartMapper.insert(newCart); } else { int newQuantity = cart.getQuantity() + quantity; cartMapper.updateQuantity(cart.getId(), newQuantity); } }改数量时要判断用户传上来的数量是否超过库存,以及是否小于等于 0。价格不直接存购物车表,每次展示时用商品当前价格乘以数量,这样商品改价后购物车不出现脏数据,代码也更简单。
4.3 下单接口的幂等性:防止重复提交订单
用户点击“提交订单”后如果网络卡顿,可能会连点两次。如果不做处理,会生成两个一样的订单。常见方案是在页面上生成一个一次性 token,提交时带到后端校验。也可以用数据库唯一约束来处理:订单号order_no是唯一键,同一用户同一时间生成的订单号只要保证唯一,重复提交会导致第二次插入失败。
我一般用UUID.replace("-", "")生成订单号,但这种方式在分布式下不保证有序。课设项目可以直接用时间戳加用户 ID:
private String generateOrderNo() { return System.currentTimeMillis() + "" + userId; }如果担心同一毫秒重复,可以在后面加随机数。实现时还可以在order表加一个user_id + create_time的组合约束,进一步防止重复插入。
5. 鲜花商城系统的页面渲染和数据传递:Thymeleaf 直接连后端
5.1 用 Thymeleaf 布局让每个页面共用导航栏和底部
不用前后端分离时,Thymeleaf 是 Spring Boot 官方推荐模板。页面文件放在src/main/resources/templates下,静态资源如 CSS、JS、图片放在src/main/resources/static下。导航栏、底部版权信息这些公共部分可以抽成一个片段模板fragments/common.html:
<html xmlns:th="http://www.thymeleaf.org"> <body> <nav th:fragment="navbar" class="navbar"> <a th:href="@{/goods/list}">首页</a> <a th:href="@{/cart/list}">购物车</a> <a th:href="@{/order/list}">我的订单</a> <span th:if="${session.loginUser != null}" th:text="${session.loginUser.nickname}"></span> </nav> </body> </html>其他页面引入方式:
<div th:replace="~{fragments/common :: navbar}"></div>th:replace会用片段的标签替换当前 div,th:insert则是把片段内容插入 div 内部。这两个的区别在页面出样式问题时经常需要排查。
5.2 商品列表页的 Controller 数据传递方式
商品列表页需要两个数据:分页后的商品列表和当前选中的分类 ID。代码:
@GetMapping("/goods/list") public String list(@RequestParam(defaultValue = "1") long pageNum, @RequestParam(defaultValue = "12") long pageSize, @RequestParam(required = false) Long categoryId, @RequestParam(required = false) String keyword, Model model) { Page<Goods> page = goodsService.pageGoods(pageNum, pageSize, categoryId, keyword); model.addAttribute("page", page); model.addAttribute("categoryId", categoryId); model.addAttribute("keyword", keyword); return "goods/list"; }defaultValue = "1"和"12"保证用户没传参数时页面不会报错。required = false是关键,categoryId 和 keyword 是可选的,如果写成 true,URL 没带参数直接 400。页面里用th:each遍历商品列表:
<div class="goods-grid"> <div class="goods-card" th:each="goods : ${page.records}"> <img th:src="@{${goods.mainImage}}" th:alt="${goods.name}"> <h3 th:text="${goods.name}">鲜花</h3> <p class="price" th:text="'¥' + ${#numbers.formatDecimal(goods.price, 1, 2)}">¥99.00</p> <a class="btn" th:href="@{/goods/detail/{id}(id=${goods.id})}">查看详情</a> </div> </div>#numbers.formatDecimal(goods.price, 1, 2)是 Thymeleaf 内置的数字格式化工具,保证价格显示保留两位小数。图片路径用th:src="@{${goods.mainImage}}",如果数据库存的是/upload/rose.jpg,会自动拼上上下文路径。
5.3 上传图片的显示问题:用静态资源映射解决 404
商城系统后台要能上传商品图片。图片如果直接传到项目目录下,重启后可能丢失;传到服务器磁盘的某个目录,页面又会 404,因为 Spring Boot 默认只映射classpath:/static/。解决办法是加一个资源映射配置:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + System.getProperty("user.dir") + "/upload/"); } }这样访问http://localhost:8080/upload/rose.jpg时会映射到项目运行目录下的upload文件夹。user.dir是 JVM 启动时的工作目录,用 IDE 启动就是项目根目录,用java -jar启动就是 jar 所在目录。生产环境一般改用绝对路径或对象存储,但课设这样够用。
6. 验证系统可靠性的三个关键点:实测记录、参数校验和慢 SQL
6.1 用“分”为单位存储金额,避免 BigDecimal 比较陷阱
鲜花商品价格如果用double存储,0.1 加 0.2 会得到 0.30000000000000004。数据库字段用decimal(10,2)能存对,但代码里做订单总金额累加时仍建议把元转成分,用long做加法。定义公共工具类:
public final class MoneyUtil { private MoneyUtil() { } public static long toFen(BigDecimal yuan) { return yuan.setScale(2, RoundingMode.HALF_UP) .multiply(BigDecimal.valueOf(100)) .longValue(); } public static BigDecimal toYuan(long fen) { return BigDecimal.valueOf(fen, 2); } }累加金额时用分相加,展示时再转回元,这样不会出浮点误差。这个点写在答辩 PPT 里很加分。
6.2 压测下单接口,观察库存是否超卖
写完下单接口后,可以开两个浏览器窗口同时登录同一个账号,对同一个商品同时下单。正常情况下一单成功一单提示库存不足,而不是两单都成功。想更严谨可以用 JMeter 模拟 50 个并发请求,观察goods.stock扣减总和是否等于初始值减订单总量。
如果发现超卖,优先检查deductStock的 XML 里是否带stock >= #{quantity}条件,以及 Service 方法上有没有@Transactional。开启 MyBatis-Plus 的 SQL 日志后,看控制台输出的 UPDATE 语句是否包含 WHERE 条件。
6.3 查询慢的接口用 EXPLAIN 验证索引
商品搜索页如果数据量上万,关键词模糊查询会很慢。先执行EXPLAIN SELECT * FROM goods WHERE name LIKE '%玫瑰%',看type列是不是ALL,rows列是不是很大。如果是,说明没走索引。LIKE '%关键字%'前置通配符会导致索引失效,这是八股文里常考的知识点,实际调优时可以把搜索改成全文索引(MySQL FULLTEXT)或加一个专门的搜索表。课上演示时,先把idx_category_status索引建上,再对比一下走分类筛选和全表扫描的查询耗时,用数据说话。
本文还有配套的精品资源,点击获取