说实话,线上花店这个选题在毕业设计里已经不算新鲜了,但为什么每年还是有一大批人做?因为电商最典型的一条链路——商品展示、购物车、结算、下单、模拟支付、发货——它全都有。如果能把这条链路在 SSM 后端和 Vue 前端之间完整打通,比单独写十个增删改查都有说服力。最近我把手头这套基于 SSM + Vue 的线上花店系统完整整理了一遍,功能、数据库、接口、前端交互、部署方式都重新过了几次,顺便把平时大家最容易踩的坑也再跑了一遍。这篇文章就把这套线上花店系统的核心实现思路、关键代码和排查经验一次写清楚,给正在做同类项目、或者拿到源码不知道怎么下手的同学当一个参考。
先介绍下项目的大致情况:后端是 Spring + SpringMVC + MyBatis,也就是常说的 SSM 组合,前端用的是 Vue 2 加 Element UI,数据库用 MySQL。整个系统分成前台用户端和后台管理端,最核心的业务无非是“花店卖花”:用户注册登录,浏览鲜花商品,加入购物车,下订单并模拟支付,管理员维护商品、处理订单、管理分类。页面交互都在 Vue 那边做,后端只提供 JSON 接口,属于很标准的前后端分离结构。也就是说,只要你能把这一套源码真正跑起来、看懂,往后去理解其他商城类系统都会轻松非常多。下面我按理解一个项目该有的顺序来拆:先看业务设计,再看表结构,然后是后端接口和前端页面,最后落到环境搭建和排障。
1. 系统整体拆分:业务范围与核心链路
1.1 这个花店系统到底做了什么
线上花店本质是一个单店自营的 B2C 商城,规模不需要做到天猫那种复杂程度,但要满足完整交易闭环。前台用户关心的动作基本是这几个:
- 浏览首页轮播图、打折商品和鲜花分类;
- 按分类筛选花束,按关键词搜索商品;
- 查看商品详情,加入购物车;
- 在购物车中修改数量、删除商品、选择结算项;
- 填写收货地址、提交订单;
- 模拟支付,把订单从“待支付”变成“已支付”;
- 在个人中心查看自己的订单状态。
后台管理端面向花店运营人员,功能对应前台的运营需求,包括商品管理、分类管理、订单管理、用户管理、轮播图管理、公告管理。商品管理里有新增、编辑、上下架、删除,订单管理里最关键的动作是发货,把订单状态往后推一步。
按模块划分就是这样一张表,跑项目之前先把这块搞明白,后面代码才不至于迷路。
| 端 | 模块 | 主要功能点 |
|---|---|---|
| 前台 | 用户模块 | 注册、登录、个人信息修改 |
| 前台 | 商品模块 | 分类浏览、搜索、详情展示 |
| 前台 | 购物车模块 | 加购、数量调整、删除、勾选结算 |
| 前台 | 订单模块 | 确认订单、模拟支付、查看订单 |
| 后台 | 商品分类 | 分类的增删改查 |
| 后台 | 商品管理 | 上新、编辑、上下架、图片上传 |
| 后台 | 订单管理 | 按状态查询、发货、查看明细 |
| 后台 | 内容管理 | 轮播图、公告、用户管理 |
从业务上看,花店和服装店、数码店最大的不同是商品“可选项”少。一束玫瑰通常就是一个商品,不需要像衣服那样区分尺码颜色,所以这份项目的数据模型非常干净,没有引入 SKU 这种复杂概念,正好适合用来练习 SSM 和 Vue 的基础能力。
1.2 为什么选 SSM + Vue,而不是直接上 Spring Boot
很多同学会问一个问题:现在企业里新项目基本都在用 Spring Boot,为什么毕业设计还有一大半用 SSM?答案不复杂,很多院校的课程阶段就教的是 Spring、SpringMVC、MyBatis 三件套,Spring Boot 反而当成“自学内容”一带而过。做毕业设计时,大家自然优先选自己课上学过的框架,老师评阅时也更容易看懂。
但从技术角度我要说句公道话:SSM 并不是不能做商城,只是配置偏多。SpringMVC 负责接收 HTTP 请求、参数绑定、返回 JSON,MyBatis 负责把数据库操作映射到接口,Spring 负责把这些 Bean 串起来。对这种日活几十人、几百人的课程设计级别系统,性能完全不是瓶颈,真正考验人的是分层是否清晰、代码是否可维护。
Vue 负责的是用户能看到的所有页面。选 Vue 而不是 JSP 的最大原因,是页面交互体验差别非常大。用 JSP 写购物车,每次按一次加减数量通常要刷新一次页面,体验很生硬;用 Vue 之后,数据是响应式的,商品数量一改,总价自动重新计算,用户几乎感觉不到等待。而且前后端分离以后,后端不需要关心页面长什么样,只要把 JSON 数据给到前端就行,这对多人协作也很友好。
1.3 一条主线看懂订单业务
我开始写这种项目时,习惯不先看 Controller,而是先把订单的“状态流”理清楚。整个花店系统的业务主线是围绕订单实时流转的:
用户注册登录后选好商品发起结算,系统生成一条状态为“待支付”的订单,同时把购物车中已勾选的商品同步成订单明细;用户点击模拟支付后,订单状态变成“已支付”,但此时管理员还没有发货;管理员在后台看到这条订单并发货,状态更新为“已发货”;用户收到花后,可以确认收货,订单进入“已完成”,整个交易闭环结束。如果在下单后反悔不想要了,可以在支付前取消订单,状态变成“已取消”。
这条业务线听起来很普通,但它牵涉到数据库里的多张表联动,例如购物车表在结算后要删除数据、商品表的下单后扣减库存、订单明细表需要保存下单时的商品价格快照,任何一步没有处理好,系统就会出现“订单生成了但库存没扣”或者“购物车商品删了但订单找不到明细”的诡异 bug。搞懂这条主线,等于拿到了整个项目的地图。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库设计:这套系统真正的地基
2.1 用户、地址、购物车:先理解一对多关系
几乎所有商城的第一张核心表都是用户表。花店系统的用户表不需要太多冗余字段,一般就是 user_id、username、password、nickname、phone、avatar、create_time。密码千万不要明文存,虽然这是毕设项目,但规范一点还是用 MD5 加密后再入库,答辩时被问到安全问题,你有东西可以回答。
用户和收货地址是一对多关系,一个用户可以存多个地址。地址表 address 的核心字段是 address_id、user_id、receiver_name、receiver_phone、省市区三级地址、详细地址以及是否默认地址。为什么地址要单独建表而不是直接挂在订单上?因为地址是长期维护的,用户第一次下单填好,第二次可以直接选,不能每次下单都重新敲一遍。
购物车表 cart_item 是很多新手容易设计错的表。比较合理的字段是 cart_item_id、user_id、goods_id、quantity、create_time。这里不建议在购物车里冗余商品价格,因为商品价格会变动,购物车只是“意向清单”,最终价格以下单时重新从商品表读取为准。同时还需要给 (user_id, goods_id) 加唯一索引,这样同一个用户多次点击同一件商品的“加入购物车”时,查询到已有记录就做数量 +1,而不是硬塞一条新记录,避免购物车出现两行一模一样的商品。
2.2 商品相关表:分类、商品与轮播
商品分类表 category 很简单:category_id、name、sort。鲜花类目常见可以设“生日花束”“爱情鲜花”“商务用花”“鲜花礼盒”,sort 字段用来控制后台展示顺序。用数字排序比在代码里写死 if 判断要灵活,以后想往中间插一个分类不用改代码。
商品表 goods 是前台展示最核心的数据来源,我建议至少包含这几个字段:
| 字段名 | 含义 | 说明 |
|---|---|---|
| goods_id | 商品ID | 主键,自增 |
| category_id | 分类ID | 关联分类表 |
| name | 商品名称 | 如“19朵红玫瑰礼盒” |
| subtitle | 副标题 | 一句话卖点 |
| main_image | 主图地址 | 保存相对路径 |
| price | 售价 | 建议用 decimal(10,2) |
| original_price | 原价 | 用于展示划线价 |
| stock | 库存 | 下单扣减 |
| sales | 销量 | 下单累加 |
| status | 状态 | 1上架 0下架 |
| detail | 图文详情 | 富文本内容 |
| create_time | 创建时间 | 用于列表排序 |
轮播图表 banner 只需要存 image、url、sort、status 几个字段,前台首页按 sort 升序查出启用状态的图就行。公告表类似,字段就是标题、内容、发布时间。这些表结构都很接近“增删改查”的标准模板,难度不大,但要注意字段类型。价格用 decimal 而不是 float,否则计算总价时容易出现 0.1 + 0.2 = 0.30000000000000004 这类经典问题。
2.3 订单表与订单明细:为什么要做快照
订单相关表是整个系统里最值得反复揣摩的部分。因为一次订单会产生两条核心数据:order 主表和 order_item 明细表。
order 表保存订单整体信息,包括订单号、用户 ID、总金额、订单状态、收货人姓名、电话、地址、下单时间、支付时间、发货时间、完成时间。注意订单号最好不要直接用自增主键,因为订单号可能要展示给用户,也会在模拟支付回调时回传,建议用时间戳加随机数生成。比如 yyyyMMddHHmmss 加四位随机数,看起来统一,也方便排查订单。
order_item 表保存订单里的每一件商品:order_id、goods_id、goods_name、goods_image、price、quantity。关键在于这里要把商品名称、图片、单价都复制一遍存下来,这就叫“快照”。为什么订单明细一定要快照?因为花店的商品价格和主图是会变的,今天卖 199 的红玫瑰明天可能活动价调整到 159,如果订单明细外键关联商品表去实时查价格,用户翻几个月前的订单时,看到的价格可能已经变了,这显然不合理。快照把下单那一刻的商品信息固定下来,历史订单才不会“变形”。
2.4 订单状态管理:统一约定状态码
状态字段是所有订单系统的灵魂。这套项目里,order 表的 status 字段建议统一约定为整数枚举,前端和后端都按同一份约定翻译。
| 状态值 | 含义 | 下一步操作 |
|---|---|---|
| 0 | 待支付 | 用户可取消或支付 |
| 1 | 已支付/待发货 | 管理员可发货 |
| 2 | 已发货 | 用户可确认收货 |
| 3 | 已完成 | 流程结束 |
| 4 | 已取消 | 流程结束 |
状态尽量用整数存,不要用字符串直接存“待支付”、“已发货”这种中文。原因有两个:一是中文状态如果后面要调整叫法,就得改数据库数据,而整数状态码只需要在前端翻译表里改显示文字;二是程序里做条件判断时,status = 0 比 status = '待支付' 更不容易因为中英文标点、空格产生脏数据。
实际开发里还有个细节,表与表之间可以适当创建索引和外键。外键在论文里写“实现数据一致性”还能说得过去,但在真实项目里会影响删除效率和扩展灵活性。我的建议是这个项目里不需要物理外键,只需要在关联字段上建普通索引,让 MyBatis 的联表查询跑得快一点就行。毕竟你后台删除一个分类时,MySQL 会因为外键约束报错,还得先判断有没有商品引用,反而麻烦。
3. 后端 SSM 落地:从包结构到核心业务实现
3.1 后端包结构与统一返回结果
SSM 项目给人的第一印象是“包多、配置多”。如果拿到源码后打开 IDEA 发现一堆报错,不用慌,先看包结构是否符合经典分层:
text复制com.flower
├── controller // 接收前端请求
├── service // 业务逻辑接口
│ └── impl // 业务逻辑实现
├── mapper // MyBatis 数据访问接口
├── entity // 实体类
├── common // 通用返回结果、常量等
├── interceptor // 登录拦截器
└── config // 配置类
controller 层只做参数接收和结果返回,不写业务;service 层负责下单、扣库存、状态流转;mapper 层只做 SQL 操作。我见过大量源码在 controller 里又查库又算钱,一时能跑,但要加一个权限判断就要改动所有接口,最后没人敢动。而分层的意义就在于“每一层只干自己那件事”,出了问题能快速定位。
所有接口的返回结果,强烈建议统一封装成一个 Result 类,最基本的结构就是三个字段:
java复制public class Result<T> {
private int code;
private String msg;
private T data;
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.setCode(200);
result.setMsg("操作成功");
result.setData(data);
return result;
}
public static <T> Result<T> error(String msg) {
Result<T> result = new Result<>();
result.setCode(500);
result.setMsg(msg);
return result;
}
}
约定 code 为 200 表示成功,401 表示未登录,500 表示业务失败。前端 axios 封装后统一拦截 code,不用每个页面重复写 if 判断。有的同学喜欢把直接查询结果丢给前端,成功不成功全靠 HTTP 状态码判断,这在前后端分离项目里会越写越乱,还是建议统一。
3.2 登录鉴权:Session 还是 Token
前后端分离项目最麻烦的一个点就是登录鉴权。如果后端用传统 HttpSession,把登录状态存在服务端,前端代码运行在 8080 端口、后端接口跑在 8088 端口,跨端口请求时 Cookie 的传递经常会出问题,出现“明明登录成功了,刷新一下又让我登录”的怪现象。做线上花店这种分离系统时,我更推荐用 Token 方案。
Token 方案核心思路是这样:用户登录成功后,后端生成一个随机 Token 并绑定到用户 ID,返回给前端;前端把它存到 localStorage 里,之后每次请求都在请求头带上这个 Token;后端写一个拦截器,对所有需要登录的接口校验请求头里的 Token 是否有效。为了减少额外依赖,可以自己维护一个 Map 当做 Token 存储:
java复制public class TokenManager {
// 简化版本:Token -> userId
private static final Map<String, Integer> TOKEN_POOL = new ConcurrentHashMap<>();
public static String generateToken(int userId) {
String token = UUID.randomUUID().toString().replace("-", "");
TOKEN_POOL.put(token, userId);
return token;
}
public static Integer getUserId(String token) {
return TOKEN_POOL.get(token);
}
public static void removeToken(String token) {
TOKEN_POOL.remove(token);
}
}
然后写一个拦截器,只校验那些“需要登录才能访问”的接口:
java复制public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) throws Exception {
// 放行预检请求
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
String token = request.getHeader("token");
Integer userId = TokenManager.getUserId(token);
if (userId == null) {
response.setStatus(401);
response.setContentType("application/json;charset=utf-8");
response.getWriter().write("{\"code\":401,\"msg\":\"未登录或登录已过期\"}");
return false;
}
request.setAttribute("currentUserId", userId);
return true;
}
}
在 SpringMVC 配置里注册拦截器时,注意路径拦截范围。不要直接拦截 /api/**,因为登录接口、注册接口、商品列表这些本来就不需要登录。建议配置成拦截 /api/user/**、/api/cart/**、/api/order/** 这种敏感前缀,而 /api/goods/** 下的商品浏览接口放行。
有同学会觉得 Session 方案其实也能跑通,那为什么非要折腾 Token?因为 Token 方案天然支持跨域跨端口,前端只需要在 axios 请求头里加一个字段就可以,不依赖浏览器对 Cookie 的策略;而且以后如果系统要支持小程序或 App,只要多端都携带 Token,后端不改逻辑也能复用。
3.3 商品分页查询:PageHelper 的用法与注意事项
商品列表页几乎都有分类筛选、关键词搜索、分页功能。如果自己不拼 SQL,用 MyBatis 通用分页插件 PageHelper 是一个非常成熟的选择。在 spring-mybatis.xml 里配置好插件后,业务代码中只要在查询前调用一行方法即可:
java复制@Override
public PageInfo<Goods> getGoodsPage(Integer pageNum, Integer pageSize, Integer categoryId, String keyword) {
// 这一行必须放在 mapper 查询之前
PageHelper.startPage(pageNum, pageSize);
List<Goods> goodsList = goodsMapper.selectByCondition(categoryId, keyword);
return new PageInfo<>(goodsList);
}
对应的 Mapper XML 中写动态 SQL:
xml复制<select id="selectByCondition" resultType="com.flower.entity.Goods">
select goods_id, name, subtitle, main_image, price, original_price, stock, sales
from goods
<where>
<if test="categoryId != null">
and category_id = #{categoryId}
</if>
<if test="keyword != null and keyword != ''">
and name like concat('%', #{keyword}, '%')
</if>
and status = 1
</where>
order by goods_id desc
</select>
这里要注意两点。第一,PageHelper.startPage 后面只能紧跟一条需要分页的查询,中间不能夹带任何其他 SQL 操作,否则分页会作用到错误的那条 SQL 上。第二,前台只需要展示上架商品,SQL 里必须带上 status = 1 这个条件,如果写漏了,后台下架的商品也会出现在商城首页,这就是一个非常隐蔽的逻辑 bug。
3.4 购物车到下单:事务和库存扣减必须同时成功
购物车相关接口相对直接:查询当前用户的购物车、添加商品、修改数量、删除某项。但真正的难点在下单接口,因为它要同时操作好几张表,业务上是一个不可分割的原子操作。
拿最简单的“从勾选的购物车项生成订单”过程来说,Service 层要做的事大致是:
- 根据 user_id 从购物车勾选项里查出商品;
- 遍历商品,从 goods 表读取当前真实价格;
- 计算订单总金额;
- 生成 order 记录,状态为 0(待支付);
- 为每个商品生成 order_item 记录;
- 扣减商品库存并累加销量;
- 删除已经下单的购物车记录。
这七个步骤必须放在同一个事务中。如果在 Spring 配置里开启了注解事务支持,就是在方法上标注 @Transactional,然后异常抛出时全部回滚。
java复制@Transactional(rollbackFor = Exception.class)
@Override
public Long createOrder(Integer userId, List<Integer> cartIds, Integer addressId) {
// 1. 根据 cartIds 查购物车明细
// 2. 校验商品是否在售、库存是否充足
// 3. 计算总价,写入 order 表
// 4. 批量写入 order_item
// 5. UPDATE goods SET stock = stock - #{count}, sales = sales + #{count}
// WHERE goods_id = #{goodsId} AND stock >= #{count}
// 6. DELETE FROM cart_item WHERE cart_item_id IN (...)
// 返回 orderId
}
扣库存这里值得单独说。很多源码写的是先 select stock 查出库存,然后判断库存大于要买的数量,再执行 update goods set stock = 新库存。单用户测试下没问题,但在高并发下会出大问题:两个用户同时读到库存只剩 1,结果都认为有货,都下了单,库存最终变成了负数。更稳妥的写法是把扣减条件和判断放进同一条 SQL:
sql复制update goods
set stock = stock - 1, sales = sales + 1
where goods_id = #{goodsId} and stock >= 1
如果这条 update 返回的影响行数为 0,说明库存已经不足,需要提示用户库存不够。这个写法没有引入复杂的锁机制,却防止了超卖,其实是一种最简单易懂的乐观锁思想,答辩时老师问并发问题你也能接得住。
3.5 图片上传和本地静态资源映射
商品主图、轮播图都是文件上传。上传接口把文件保存到本地磁盘一个目录,把相对路径返回给前端,前端再把相对路径拼到商品数据里。数据库里不要存 C:\Users\xxx\Desktop\flower\images\a.jpg 这种绝对
