1. 网上鲜花销售平台的选题定位与整体思路
网上鲜花销售平台,套上SpringBoot,是这个阶段非常典型、也非常能出成果的JavaWeb毕业设计选题。它既不是那种随便找个博客系统糊弄的题目,也不算冷门偏门,属于业务模型完整、技术点覆盖均匀、演示效果直观的“标准款”。整个系统围绕用户、商品、购物车、订单这一条线展开,鲜花作为一种有图片展示、有分类筛选、有价格库存的实体商品,非常适合用来撑起电商系统的核心链路。
这类项目的核心诉求通常集中在三个地方:一是功能结构能不能讲清楚,二是业务流程能不能跑通,三是技术上有没有拿得出手的亮点。很多同学把精力放在怎么写代码上,实际上评审老师更看重的是逻辑的一致性——用户登录后能加购、能下单、能支付(模拟)、能在后台发货,订单状态对不对,库存扣得对不对,这些才是真正决定项目质量的东西。
我在帮人看这类项目的时候,发现一个普遍现象:很多人把系统做得很大,会员、积分、优惠券、秒杀全塞进来,结果代码写到后面自己都理不清,答辩的时候被问几个关联问题就卡住了。所以这套鲜花销售平台,我建议做成一个“功能压得准、链路走得通、细节有亮点”的完整闭环,而不是大而全的半成品。这篇文章会把从设计到实现的关键环节全部拆开来讲,适合准备做毕设、或者想快速搭建一个标准电商Demo的开发者参考。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型:为什么SpringBoot单体应用最适合这类毕设
2.1 SpringBoot解决的本质问题
SpringBoot出现在这里,不只是因为名字好听,它解决的实际问题是:让Spring应用的搭建和运行变得足够简单。早期用SSH、SSM那套,光配置XML就够折腾好几天,而SpringBoot通过自动配置把大量样板配置去掉了。对毕设来说,这意味着你能把时间花在业务流程而不是环境适配上面。
另外一个容易被忽略的点是,SpringBoot内嵌了Tomcat容器,打成Jar包后一条命令就能跑起来。答辩的时候演示,不用再单独配置外部Tomcat,这对实战演示的稳定性帮助非常大。我见过太多项目在换了一台机器之后因为环境问题起不来,用SpringBoot之后这类问题基本被消灭了。
2.2 技术栈明细与版本搭配建议
这套鲜花商城最稳妥的技术组合如下:
- 后端框架:Spring Boot 2.7.x(对应JDK8,兼容性最好,不要盲目上3.x)
- 持久层:MyBatis-Plus(省去大量单表CRUD的XML编写)
- 数据库:MySQL 5.7或者8.0都可以,推荐5.7,稳妥
- 模板引擎:Thymeleaf(服务端渲染,不需要额外部署前端工程)
- 前端辅助:Bootstrap或Layui(不需要花太多精力写原生CSS)
- 构建工具:Maven
- 安全控制:Spring Boot拦截器 + Session(不引入Spring Security,太重)
这里有一个很实用的建议:Spring Boot版本选2.7.x这一代,而不是最新的3.x,是因为2.7已经足够成熟,网上资料最多,碰到问题搜得到答案。3.x要求JDK17,很多学校的实验环境还停留在JDK8,一旦版本不适配,你花在环境上的时间可能比写代码还多。
2.3 为什么不是前后端分离,也不是微服务
很多人在选题时会纠结:要不要用Vue做前后端分离?要不要加个Eureka、Nacos?我的意见很明确:毕设项目追求的是完整度,不是架构复杂度。前后端分离意味着你要同时维护两个工程,接口文档、跨域配置、登录状态传递,每一项都会增加工作量。而用Thymeleaf做服务端渲染,所有页面直接在后端拼好返回,代码量少,逻辑清晰,答辩时讲解也方便。
至于微服务,更不建议。一个鲜花商城的核心业务就是商品和订单,单体应用完全能撑住。强行拆分成多个服务,反而会让事务、调用链、部署复杂度成倍增长,除非你的课题明确要求分布式,否则没必要自找麻烦。这是一个典型的“技术选型大于技术本身”的场景,选对了,事半工倍。
3. 数据库设计:八张表撑起一个完整闭环
数据库设计是这类项目的根基。我见过很多半路翻车的项目,问题不是代码写不出来,而是表设计有漏洞,比如订单表里不存商品快照,时间一长商品改了价格,历史订单金额就对不上了。
3.1 核心表结构与字段说明
对于鲜花商城,我建议用八张表来覆盖全部业务:
| 表名 | 用途说明 | 核心字段 |
|---|---|---|
| user | 用户表 | id, username, password, nickname, phone, avatar, create_time |
| address | 收货地址表 | id, user_id, consignee, phone, province, city, district, detail, is_default |
| category | 商品分类表 | id, name, parent_id, sort |
| flower | 鲜花商品表 | id, category_id, name, subtitle, main_image, detail, price, stock, sales, status |
| cart | 购物车表 | id, user_id, flower_id, quantity, checked |
| orders | 订单主表 | id, order_no, user_id, total_amount, pay_amount, status, address_snapshot, create_time, pay_time, delivery_time |
| order_item | 订单明细表 | id, order_id, flower_id, flower_name, flower_image, price, quantity |
| banner | 轮播图表 | id, image, link_url, sort |
需要注意一个细节:flower表里的status字段,用0表示下架,1表示上架。很多初学者会直接删除商品记录,这会带来严重问题——如果这个商品已经被订单引用了,删除后订单明细就会变成空壳数据。正确的做法是逻辑删除,即通过状态字段控制显示,而不是物理删除。
3.2 订单表为什么要冗余字段
这是设计要点,也是答辩加分点。address_snapshot这个字段是用来存储下单时收货地址的完整快照(比如用JSON格式拼接省市区+详细地址+手机号),这样即使后来用户修改了地址,甚至删除了地址记录,订单本身仍然保留了下单时的地址信息。同理,order_item表里冗余了flower_name、flower_image和price,也是为了保证商品信息变更不影响历史订单的展示。
这个思路可以用一句话概括:订单是历史事实,不允许被后续变更篡改。你把这条讲给评审老师听,比背一百个八股文都管用。
3.3 订单状态机与流转设计
订单状态的流转设计要清晰。实践中我常用一组整型常量来表示:
| 状态值 | 含义 | 说明 |
|---|---|---|
| 0 | 待支付 | 下单后未支付 |
| 1 | 待发货 | 已支付成功 |
| 2 | 待收货 | 商家已发货 |
| 3 | 已完成 | 用户确认收货 |
| 4 | 已取消 | 超时或手动取消 |
| 5 | 退款中 | 用户发起退款,管理员审核 |
这里要注意的是,状态流转不能乱跳。待支付只能取消或变成待发货,待发货只能变成待收货,不能出现从待支付直接跳到已完成的情况。代码层面可以在Service里加状态校验,比如更新订单时带上 AND status = 预期值 的条件,防止并发请求下状态被覆盖。
4. 后端核心模块实现与关键代码
网上有太多“只讲概念不讲代码”的文章,我这里反着来——把最容易出彩的几个核心模块直接拆开讲,给你能直接落地的方案。
4.1 登录拦截器与用户会话管理
用户登录后状态存在Session里,这个是单体应用最自然的做法。关键点在于,不能在每个接口里都写一堆重复的Session判空,而是用拦截器统一处理。
java复制public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
Object user = request.getSession().getAttribute("loginUser");
if (user == null) {
// 判断是否Ajax请求
String requestType = request.getHeader("X-Requested-With");
if ("XMLHttpRequest".equals(requestType)) {
response.setStatus(401);
} else {
response.sendRedirect("/login");
}
return false;
}
return true;
}
}
然后在配置类里注册拦截器,注意放行路径要写对:
java复制@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.addPathPatterns("/**")
.excludePathPatterns(
"/login",
"/register",
"/css/**",
"/js/**",
"/images/**",
"/flower/list",
"/flower/detail/**",
"/banner/list",
"/error"
);
}
}
这里有一个比较隐蔽的坑:Ajax请求被拦截后,如果直接重定向到登录页,前端拿到的是一整个HTML字符串,解析JSON时会报错。所以上面代码判断了 X-Requested-With 请求头,对于Ajax请求返回401状态码,前端再根据状态码跳转登录页,这个细节体现了实战经验,建议保留。
4.2 购物车模块:数据库方案与重复商品处理
购物车实现有两种思路:一种是存Redis,一种是存数据库表。对于毕设场景,Redis不是必须的,数据库方案完全够用,而且逻辑更容易讲清楚。
购物车表记录了user_id和flower_id,当用户重复添加同一个商品时,不要插两条记录,而是把quantity数量加一。这个逻辑很多人都会漏掉,导致购物车里同一个花出现两行,看起来很业余。
java复制@Override
public void addToCart(Long userId, Long flowerId, Integer count) {
Cart cart = cartMapper.selectOne(new LambdaQueryWrapper<Cart>()
.eq(Cart::getUserId, userId)
.eq(Cart::getFlowerId, flowerId));
if (cart == null) {
Cart newCart = new Cart();
newCart.setUserId(userId);
newCart.setFlowerId(flowerId);
newCart.setQuantity(count);
newCart.setChecked(true);
cartMapper.insert(newCart);
} else {
cart.setQuantity(cart.getQuantity() + count);
cartMapper.updateById(cart);
}
}
还有一个实用处理:用checked字段表示该商品是否被选中。购物车页面通常有“全选/单选”的操作,结算时只计算checked为true的商品。如果不做这个字段,结算逻辑就得靠页面上传一串商品ID,反而更麻烦。
4.3 下单事务:扣库存、生成订单、清空购物车
下单是整个系统里最体现基本功的地方,核心在于事务控制。一个完整下单操作包含了三件事:扣减商品库存、生成订单主表和订单明细、清空购物车对应商品。这三步要么全部成功,要么全部回滚,必须放在同一个事务里。
java复制@Transactional(rollbackFor = Exception.class)
public Order createOrder(Long userId, Long addressId, List<Long> cartIds) {
// 1. 查询用户选中的购物车数据
List<Cart> cartList = cartMapper.selectBatchIds(cartIds);
// 2. 计算总金额
BigDecimal totalAmount = BigDecimal.ZERO;
List<OrderItem> orderItems = new ArrayList<>();
for (Cart cart : cartList) {
Flower flower = flowerMapper.selectById(cart.getFlowerId());
if (flower == null || flower.getStatus() != 1) {
throw new RuntimeException("商品不存在或已下架");
}
if (flower.getStock() < cart.getQuantity()) {
throw new RuntimeException(flower.getName() + "库存不足");
}
// 扣减库存,注意这里加了stock >= quantity条件
int rows = flowerMapper.deductStock(flower.getId(), cart.getQuantity(), flower.getStock());
if (rows == 0) {
throw new RuntimeException(flower.getName() + "库存扣减失败");
}
// 3. 构建订单明细
OrderItem item = new OrderItem();
item.setFlowerId(flower.getId());
item.setFlowerName(flower.getName());
item.setFlowerImage(flower.getMainImage());
item.setPrice(flower.getPrice());
item.setQuantity(cart.getQuantity());
orderItems.add(item);
totalAmount = totalAmount.add(flower.getPrice().multiply(BigDecimal.valueOf(cart.getQuantity())));
}
// 4. 生成订单主表
Order order = new Order();
String orderNo = "FL" + System.currentTimeMillis() + RandomUtil.randomNumbers(4);
order.setOrderNo(orderNo);
order.setUserId(userId);
order.setTotalAmount(totalAmount);
order.setStatus(0);
// ... 设置地址快照等
orderMapper.insert(order);
// 5. 批量插入订单明细
orderItemMapper.insertBatch(order.getId(), orderItems);
// 6. 清空购物车
cartMapper.deleteBatchIds(cartIds);
return order;
}
扣库存这里我用的SQL是:
sql复制UPDATE flower SET stock = stock - #{quantity}, sales = sales + #{quantity}
WHERE id = #{id} AND stock >= #{quantity}
注意最后这个 stock >= #{quantity} 条件,它保证了在高并发场景下,两个人同时下单时库存不会被扣成负数。修改的行数如果是0,说明库存不足或者有人抢先扣了,直接抛出异常让事务回滚。这是“乐观锁”思路的简化实现,既简单又能讲出道理。
4.4 商品图片上传与本地映射
图片上传是毕设的标配功能,但很多人在路径上栽跟头。后端保存图片用的物理路径,和浏览器访问的URL路径不是一回事。这里给出一个稳妥的做法。
首先配置本地存储路径,在application.yml中:
yaml复制upload:
dir: D:/upload/flower-images/
上传接口的核心逻辑:
java复制public String uploadImage(MultipartFile file) {
if (file.isEmpty()) {
throw new RuntimeException("上传文件不能为空");
}
// 获取原始文件名后缀
String originalFilename = file.getOriginalFilename();
String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
// 用UUID重命名,防止文件名冲突
String newFileName = UUID.randomUUID().toString().replace("-", "") + suffix;
File dir = new File(uploadDir);
if (!dir.exists()) {
dir.mkdirs();
}
try {
file.transferTo(new File(uploadDir + newFileName));
} catch (IOException e) {
throw new RuntimeException("文件上传失败");
}
return newFileName;
}
然后需要做一个静态资源映射,把URL路径映射到本地磁盘路径:
java复制@Configuration
public class WebConfig implements WebMvcConfigurer {
@Value("${upload.dir}")
private String uploadDir;
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/upload/**")
.addResourceLocations("file:" + uploadDir);
}
}
这样页面里的图片地址就可以写成 /upload/xxx.jpg ,浏览器会到本地磁盘找对应文件。如果服务器是Linux系统,把路径换成 /usr/local/upload/flower-images/ 类似的位置即可。这个映射配置的逻辑,本质上是“把URL翻译成磁盘路径”,理解了它你就明白了静态资源的访问原理。
5. 前端页面渲染与商品检索的实操处理
这套系统的前端我用Thymeleaf来做服务端渲染,不引入Vue等框架,但页面展示效果和交互体验依然可以做得很完整。
5.1 Thymeleaf服务端渲染的优势
用Thymeleaf的好处是数据和页面在同一次请求中组装完成。控制器返回数据模型,模板引擎直接渲染成HTML响应给浏览器,整个流程简单直接。比如首页商品列表:
java复制@GetMapping("/flower/list")
public String list(@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "8") Integer pageSize,
Model model) {
IPage<Flower> page = flowerService.page(new Page<>(pageNum, pageSize),
new LambdaQueryWrapper<Flower>().eq(Flower::getStatus, 1));
model.addAttribute("page", page);
return "flower/list";
}
模板中通过 th:each 循环渲染商品卡片,商品图片直接用 /upload/ 拼接字段,价格等数据直接展示。这套模式对没有前端基础的开发者非常友好,不用处理复杂的状态同步,天然没有跨域问题,登录状态通过Session传递也很自然。
5.2 分页查询封装
列表分页用MyBatis-Plus的IPage接口就行,但要注意参数不能由前端随意定义大小。常见做法是限制pageSize上限,防止有人把每页条数调到1000,影响查询性能。这里可以做一个简单的PageRequest封装,把页码、大小、排序、关键词统一管理。前端页面则要在底部渲染页码条,逻辑很简单,拿到总页数后循环输出即可。
5.3 关键词搜索防注入处理
搜索功能一般就是按名称模糊查询:
java复制LambdaQueryWrapper<Flower> wrapper = new LambdaQueryWrapper<>();
wrapper.like(StringUtils.isNotBlank(keyword), Flower::getName, keyword.trim());
这样写MyBatis-Plus会自动做参数绑定,不会产生SQL注入问题。但如果你选择手写XML,注意要这样写:
xml复制<select id="searchByKeyword" resultType="com.example.pojo.Flower">
SELECT * FROM flower
WHERE status = 1
AND name LIKE CONCAT('%', #{keyword}, '%')
</select>
不要自己去拼接 % 到SQL字符串里,用 CONCAT 函数配合 #{} 占位符,这是防止注入的标准写法。
6. 开发中高频问题与排查实录
最后这部分,我把这个项目开发中真正容易踩的坑整理成了一份速查表,每一条都来自实际反馈,不是网上抄来的理论。
6.1 问题速查表
| 常见问题 | 产生原因 | 解决方案 |
|---|---|---|
| 上传图片后页面无法访问 | 没有配置静态资源映射 | 在配置类里添加 /upload/** 映射到本地目录 |
| 商品没有图片但主图不显示 | 数据库存了相对路径,前端没有拼接URL前缀 | 模板引擎渲染时统一拼接 /upload/ 前缀 |
| 登录后跳转到首页又变回未登录 | Session保存时机不对或者前端没有保存Cookie | 检查登录成功后是否调用了 session.setAttribute,确认浏览器没有禁用Cookie |
| 时间显示成一大串数字 | Java时间类型序列化成长整型时间戳 | 字段上添加 @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") 注解 |
| 购物车重复商品出现两行 | 添加购物车时没有先查询是否已存在 | 先按user_id和flower_id查记录,存在则更新数量 |
| 库存被扣成负数 | 扣库存SQL没有加库存充足条件 | 使用 WHERE stock >= #{quantity} 原子条件 |
| 删除分类而分类下还有商品 | 直接删除父级分类,导致商品变成无主 | 先校验该分类下是否有商品,有则提示不允许删除或做逻辑删除 |
| Thymeleaf页面报模板解析错误 | 模板标签属性拼写有误 | 检查 th: 开头的属性是否都正确闭合 |
6.2 一定要避开的三个低级错误
第一个是把密码明文存储在数据库里。哪怕只是毕设,也要用MD5加盐或者BCrypt做一次加密。这种代码写出来才像一个正规开发者做的事情,答辩老师看到明文密码印象会打折扣。
第二个是支付功能的实现方式。现实中接微信或支付宝支付需要企业资质,你个人做毕设不可能有,所以模拟支付是最稳妥的:订单状态0表示待支付,点击“模拟支付”后直接把状态改成1。你可以在页面备注一句“演示环境模拟支付成功”,这不仅不扣分,反而说明你考虑到了真实支付渠道的接入成本问题。
第三个是测试数据要足够真实。系统里不能只有两三朵花几条订单,至少要准备20个商品分布在5个分类下,再生成若干用户订单。数据量太少时,分页、搜索、统计功能根本没有展示效果。这里可以写一个简单的SQL脚本,用循环插入测试数据,这个技巧能极大提升演示效果。
6.3 答辩讲解重点
面对评审老师时,你的讲解顺序建议是:先在首页演示商品浏览和搜索,然后登录,加入购物车,下单并支付,演示订单状态从待支付变为待发货,再到后台“发货”,用户端看到待收货状态,最后确认收货变成已完成。这个过程全部串起来只需要几分钟,但完整呈现了整个业务闭环。
代码层面,重点讲三个地方:登录拦截器的统一校验、下单事务的原子性保证(扣库存+生成订单+清空购物车)、图片上传的静态映射原理。把这三个点讲透,技术深度已经完全够用。
7. 从毕设角度再补充几句
这个项目做下来,我个人的体会是:它最核心的价值不在于Stack选了多新,而在于业务流程是否严密、代码结构是否清晰、演示闭环是否完整。把用户、商品、购物车、订单这条链路走顺,再搭配几个亮点细节,答辩就站得住。
如果你还有余力,可以继续扩展:引入Redis缓存首页热门商品、接入定时任务处理超时未支付订单、增加后台数据统计报表、用WebSocket做客服聊天功能。这些扩展方向每一个都不难,但对于成绩和简历而言,都能增加实打实的含金量。
最后再分享一个小技巧:项目完成后,写一份三五页的开发文档,把表结构截图、核心流程图、接口清单整理进去,然后提前把Jar包部署到一台干净的环境里跑一遍。你会发现,提前排掉的每一个坑,都是答辩时从容回答的底气。
