简介:这是一份面向花卉贸易行业的管理系统Java项目,适用于需要学习企业级业务系统开发的开发者,或希望快速搭建进销存与客户管理一体化平台的中小花卉企业。系统按描述覆盖种植、库存、订单、销售、CRM、财务、报表及权限等环节,可帮助使用者理解从业务流程梳理到技术落地的完整路径。压缩包采用RAR格式,整体大小约35.97MB,由于上游未提供文件总数与类型明细,具体目录结构需下载后自行查看。资源已有624人学习,属于以Java技术栈为基础的业务系统参考实现,开发中可能涉及Spring、Hibernate以及React或Vue等前端框架,适合用于课程设计、毕业设计,也可作为企业二次开发的底稿,尤其有助于学习者掌握多模块系统的分层设计、数据库操作和权限控制思路。
1. 花卉销售系统:一张订单背后要管的三本账
「花卉销售系统」这个标题看着简单,真正动手时却容易掉进一个误区:把它当成「商品展示 + 下单」两个页面来做。我去过一家开了七八年的花店,每天最忙的时段不是卖花,而是对着送货单核对三件事:客人订的是哪种规格的花、今天还剩多少可砍的库存、这单要在哪个时段送到。这三个问题的答案落到系统里,就是商品、库存、订单三类数据的联动关系。
一套完整的方案通常要同时服务两类人:前台顾客在商城页按花语、场景、价格筛选下单,后台店员完成上架、改价、调库存、发货这些日常操作。如果你正为课程设计选题,或者想给身边的花店做一套真正能跑的系统,这篇文章按我做过同类方案的经验,从建表开始拆到订单、支付与上线验收,每个环节都给你能直接抄的代码和参数。新手能跟着搭完,熟手能跳过基础直接看避坑清单。
2. 先拆表再写接口:门店卖花需要哪四组核心表
很多人一上来就写登录注册,结果做到一半发现购物车没地方存、订单对不上账,只能推倒重来。我的习惯是先从一张手写送货单反推数据模型,把表和字段定清楚再写接口,后面会顺很多。
2.1 从一张手写送货单倒推数据模型
花店的送货单上通常有这些信息:收花人姓名、电话、地址、备注(比如“不要满天星”);商品部分写着「雪山玫瑰 33 枝」「开业花篮 1 个」;最下面是总价和订单状态。把这张单子拆开看,就得到订单主表、订单明细表、商品表、用户表四组核心结构。
订单主表存的是「这一单」的整体信息:订单号、下单人、总金额、状态、收货信息、各状态时间点。订单明细表存的是「订单里每一件商品」的信息:商品名、单价、数量、小计。之所以要拆成两张表,是因为一个订单可能同时包含一束玫瑰和一盆绿植,它们的价格、数量、发货方式都不一样,不能混在主表里。
商品表负责「能卖什么」:名称、分类、主图、售价、库存、上下架状态。用户表负责「谁在买、谁在卖」:登录名、密码散列、角色。再加上一张购物车表,整个系统的数据骨架就齐了。记住一个原则:订单明细里的价格必须是下单那一刻的「快照」,不能下单后去商品表实时查价,否则后台一改价,历史订单全部跟着变。
2.2 花材不是标准品:分类、规格与「花语」字段
卖花和卖数码产品有个明显区别:花材不是标准品。「玫瑰」要分红玫瑰、白雪山、香槟玫瑰,还要分单支、11 支、33 支、99 支;绿植有土培、水培、迷你盆栽;开业花篮、会议用花又是另一种形态。所以分类表必须支持多级结构,商品表必须预留规格描述字段。
我在设计时给商品表加了一个「场景标签」字段,这是花卉行业特有的运营维度:送恋人、送长辈、乔迁、开业、探病。前台搜索时可以直接按场景筛,后台做活动也方便。比如情人节把「送恋人」标签的商品置顶,母亲节把康乃馨相关商品推首页,这些操作如果靠人工改推荐位会累死店员。
花语文案也别忽视。同一个商品,用户可能记不住品种名,但会搜「毕业送什么花」「道歉适合什么花」。description 字段里埋入花语和适用场景,搜索模块可以直接 LIKE 匹配,不用额外做标签系统。对小体量门店来说,这比上一套 Elasticsearch 实在得多。
2.3 建库脚本与字段选型理由
下面这份 DDL 是我做这类系统时常用的骨架,删掉了部分不影响主流程的冗余字段,保留核心部分。直接存成 database.sql 执行即可。
-- 用户表:角色区分前台顾客与后台店员 CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(30) NOT NULL UNIQUE COMMENT '登录名', password VARCHAR(100) NOT NULL COMMENT 'BCrypt 散列值', phone VARCHAR(20) COMMENT '手机号', role TINYINT NOT NULL DEFAULT 0 COMMENT '0 顾客,1 店员,2 店长', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='登录用户表'; -- 分类表:多级分类,parent_id 为 0 表示顶级类目 CREATE TABLE flower_category ( id BIGINT PRIMARY KEY AUTO_INCREMENT, parent_id BIGINT NOT NULL DEFAULT 0, name VARCHAR(30) NOT NULL, sort_order INT NOT NULL DEFAULT 0 COMMENT '同级排序,越小越靠前' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='花卉分类表'; -- 商品表:库存、场景标签、上下架状态一起管理 CREATE TABLE flower_goods ( id BIGINT PRIMARY KEY AUTO_INCREMENT, category_id BIGINT NOT NULL, name VARCHAR(50) NOT NULL COMMENT '商品标题,如「雪山玫瑰 33 支」', cover_url VARCHAR(255) COMMENT '主图路径', description VARCHAR(500) COMMENT '花语与文案', price DECIMAL(10,2) NOT NULL COMMENT '售价', original_price DECIMAL(10,2) COMMENT '划线价,可为空', stock INT NOT NULL DEFAULT 0 COMMENT '可售库存', shelf_status TINYINT NOT NULL DEFAULT 1 COMMENT '1 上架,0 下架', scene_tag VARCHAR(20) COMMENT '场景标签:送恋人/送长辈/乔迁', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='花材商品表'; -- 购物车表:同一用户对同一商品只保留一条记录 CREATE TABLE cart_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, goods_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 1, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_goods (user_id, goods_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='购物车表'; -- 订单主表:订单号唯一,状态分开记录各环节时间 CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE 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_name VARCHAR(30) NOT NULL, receiver_phone VARCHAR(20) NOT NULL, receiver_address VARCHAR(200) NOT NULL, remark VARCHAR(200) COMMENT '订单备注,如配送时段', pay_time DATETIME COMMENT '支付时间', deliver_time DATETIME COMMENT '发货时间', finish_time DATETIME COMMENT '完成时间', cancel_time DATETIME COMMENT '取消时间', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表'; -- 订单明细表:价格字段存快照,之后商品改价不影响历史订单 CREATE TABLE order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, goods_id BIGINT NOT NULL, goods_name VARCHAR(50) NOT NULL, goods_cover VARCHAR(255) COMMENT '下单时的商品主图', price DECIMAL(10,2) NOT NULL COMMENT '下单时单价快照', quantity INT NOT NULL, subtotal DECIMAL(10,2) NOT NULL COMMENT '小计' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';字段选型理由说一下。第一,所有金额用 DECIMAL(10,2) 而不用 DOUBLE,花店对账必须精确到分,浮点运算会埋雷。第二,订单号单独建字段并加 UNIQUE,不用自增 id 当订单号暴露给用户,避免别人通过 id 遍历你的订单数据。第三,状态用 TINYINT 配合状态码注释,而不是直接存字符串,数据库体积小、索引快,代码里定义常量即可。第四,订单主表把 pay_time、deliver_time 等时间字段单独列出来,而不是只留一个 update_time,后续做「超时未支付自动取消」时直接按 pay_time 判断,逻辑清晰。
提示:MySQL 里 order 是保留字,表名建议统一用 orders,否则每次查询都要加反引号,既丑又容易踩坑。
3. 后台与商城双端落地:Session 登录、商品管理与购物车
数据模型定完,下一步是同时做后台管理和前台商城两个入口。这里先回答一个最常见的选型问题:到底要不要用前后端分离。
3.1 为什么不建议一上来就用前后端分离
如果你是为课程设计或小门店做系统,我的建议是先用单体应用:Spring Boot 做后端,服务端渲染页面配 JQuery 和一套现成的后台模板。前后端分离确实时髦,但意味着要维护两个工程、处理跨域、联调接口,对单人开发来说交付成本翻倍,门店场景也没有高并发压力,有点得不偿失。
服务端渲染还有个好处:登录态天然由 Session 管理,不涉及 Token 过期、刷新、拦截器放行一堆繁琐配置。下单、购物车这类操作,服务端渲染页面直接提交表单或发 AJAX 请求,流程好追踪、好排查。等系统真需要小程序端或 App 端时,再把接口层抽出来也不迟,订单表设计不会因为前端形态而改变。
3.2 登录拦截器与角色区分
前台顾客和后台店员不能共用同一套权限逻辑。顾客访问 /cart/、/order/,店员额外访问 /admin/**。用拦截器做统一校验比在每个 Controller 里判断更省事,把「有没有登录」和「是不是店员」分两层处理。
@Component public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); SysUser loginUser = (SysUser) session.getAttribute("loginUser"); if (loginUser == null) { // AJAX 请求返回 JSON,页面跳转返回 302,分开处理更友好 String requestedWith = request.getHeader("X-Requested-With"); if ("XMLHttpRequest".equals(requestedWith)) { response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"登录已过期,请重新登录\"}"); } else { response.sendRedirect("/login"); } return false; } // 如果访问后台路径但不是店员以上角色,直接拦截 String uri = request.getRequestURI(); if (uri.startsWith("/admin/") && loginUser.getRole() < 1) { response.setStatus(403); return false; } return true; } }逻辑说明:先判断是否登录,未登录时区分 AJAX 和普通页面请求——普通页面跳转到登录页,AJAX 返回 401 状态码让前端提示用户重新登录,避免用户在页面里点了下单才发现 Session 失效。再判断后台路径的角色要求,role 字段 1 表示店员、2 表示店长,小于 1 的普通顾客无权访问后台。
参数说明:请求头 X-Requested-With 不是浏览器自动带的,JQuery 的 $.ajax 会默认加,原生 XMLHttpRequest 不会。如果你前端用的 fetch,需要手动设置。拦截器注册时放行 /login、/goods/、/upload/这些无需登录就可以访问的资源,具体路径在 WebMvcConfigurer 里用 addPathPatterns 配置,我一般把拦截范围写成 /cart/、/order/、/user/、/admin/。
3.3 后台商品管理的三个高频操作怎么写
后台最常用的操作就三个:上架新花、修改价格、调整库存。这三个操作看似简单,但有一个共同点:都要做状态校验。比如「下架的商品能不能改价」?业务上是可以的,改完重新上架直接生效;但「下架的商品能不能被顾客搜到」?不行。所以商品查询接口默认只查 shelf_status = 1 的数据,后台管理接口再查全部。
@Service public class GoodsServiceImpl implements GoodsService { // 修改价格:只更新价格字段,不影响库存和上下架状态 public void updatePrice(Long goodsId, BigDecimal newPrice, Long operatorId) { if (newPrice == null || newPrice.compareTo(BigDecimal.ZERO) <= 0) { throw new BizException("价格必须大于 0"); } FlowerGoods goods = new FlowerGoods(); goods.setId(goodsId); goods.setPrice(newPrice); goodsMapper.updateById(goods); } // 调整库存:支持正数和负数,但结果不能小于 0 public void adjustStock(Long goodsId, int delta) { UpdateWrapper<FlowerGoods> uw = new UpdateWrapper<>(); uw.eq("id", goodsId).setSql("stock = stock + " + delta).ge("stock", -delta); int rows = goodsMapper.update(null, uw); if (rows == 0) { throw new BizException("库存调整后不能为负数"); } } }逻辑说明:updatePrice 里用 BigDecimal 做比较,newPrice 小于等于 0 直接拒绝,避免接口被乱刷导致商品出现 0 元或负数价格。adjustStock 用一条 UPDATE 完成「调整并校验」,如果调整后库存为负则影响行数为 0,抛出异常。这种写法比先查询再计算更安全,也能避免两个店员同时操作时互相覆盖。
参数说明:MathContext 和 setScale 这两个点新手容易忽略。BigDecimal 做金额计算时建议显式指定保留两位小数,并在写入数据库前调用 setScale(2, RoundingMode.HALF_UP),否则某个除法操作可能产生无限小数导致入库报错。后端在做金额计算时所有中间结果都该用 BigDecimal,不要混用 double。
3.4 商城购物车最小链路:添加到购物车与列表
购物车表已经用 UNIQUE KEY 约束了同一用户对同一商品只能有一条记录,所以添加购物车的逻辑就两种:没有该商品则新增,已有该商品则数量相加。
@Service public class CartServiceImpl implements CartService { public void addToCart(Long userId, Long goodsId, Integer quantity) { if (quantity == null || quantity <= 0 || quantity > 99) { throw new BizException("购买数量需在 1~99 之间"); } FlowerGoods goods = goodsMapper.selectById(goodsId); if (goods == null || goods.getShelfStatus() != 1) { throw new BizException("商品不存在或已下架"); } CartItem cart = cartMapper.selectOne( new LambdaQueryWrapper<CartItem>() .eq(CartItem::getUserId, userId) .eq(CartItem::getGoodsId, goodsId)); if (cart == null) { cart = new CartItem(); cart.setUserId(userId); cart.setGoodsId(goodsId); cart.setQuantity(quantity); cartMapper.insert(cart); } else { int newQty = cart.getQuantity() + quantity; cart.setQuantity(Math.min(newQty, 99)); cartMapper.updateById(cart); } } }逻辑说明:先做商品存在性和上架状态校验,下架商品加入购物车是常见错误操作来源。再查购物车是否存在同商品记录,存在则合并数量,并用 Math.min 限制单件商品最多 99 个,防止用户恶意刷数量。要注意的是这里只校验了「数量上限」,真正的库存校验留到下单环节,原因是购物车里的商品可能放了很多天,库存随时在变,下单时校验才有意义。
参数说明:LambdaQueryWrapper 是 MyBatis-Plus 的链式查询写法,eq 表示相等条件,避免手写字符串字段名导致编译期查不出错误。quantity 上限 99 是业务参数,如果做的是批发花材可以调大,零售场景 99 已经够用。购物车列表接口就简单了,查 cart_item 关联 flower_goods,按创建时间倒序返回,前端展示商品图、名称、单价、数量和小计,小计金额在后端算好,别让前端拿单价乘数量,否则可能出现浮点误差。
4. 订单是系统的命脉:库存锁定、状态机与防重复支付
购物车做完,重头戏来了:下单。这一章是整个「花卉销售系统」最容易翻车的地方,也是面试、答辩时最值得讲的亮点。三个核心问题:库存怎么扣才不超卖、订单状态怎么流转才不会乱、支付回调怎么处理才不重复入账。
4.1 减库存必须原子化:一条 UPDATE 防超卖
新手最常见的写法是先 SELECT stock,判断大于购买数量,再 UPDATE 减库存。这在单体低并发下看起来没问题,但两个用户同时下单时会出现经典的「先读后写」竞态:两个请求都读到库存剩 1,都认为可以买,结果都执行了减库存,库存变负。正确的做法是把判断和更新合并成一条 SQL。
public boolean tryReduceStock(Long goodsId, Integer num) { UpdateWrapper<FlowerGoods> uw = new UpdateWrapper<>(); uw.eq("id", goodsId) .ge("stock", num) .setSql("stock = stock - " + num); return goodsMapper.update(null, uw) > 0; }逻辑说明:关键在 update 语句的 WHERE 条件里带了 stock >= num,数据库行锁保证同一时间只有一个请求能更新成功。库存足够时执行 update 返回 1,库存不足时条件不满足返回 0。这就是乐观锁思想在库存场景的落地,不需要额外引入 Redis 分布式锁。
参数说明:setSql 里直接拼接 num 有一定风险,如果 num 是外部传入的字符串可能产生 SQL 注入。我这里 num 已经在 Controller 层用 Integer 接收并做了范围校验,所以相对安全。更严谨的做法是用 UpdateWrapper 的 set 方法配合数据库表达式,但可读性会差一些。另外,这条方法的返回值很重要,调用处必须根据返回值决定是否继续下单流程。
4.2 订单状态机的流转与防跳步
订单状态用整数存数据库,但代码里不能到处写魔法数。先定义状态常量和一张流转表,让整个团队(包括未来的你自己)都清楚每个状态能做什么操作。
| 状态 | 名称 | 允许的操作 | 说明 |
|---|---|---|---|
| 0 | 待支付 | 支付、取消 | 下单后生成,超时未支付可关闭 |
| 1 | 已支付 | 发货、退款 | 支付回调成功后进入 |
| 2 | 已发货 | 确认收货 | 记录运单号后可展示物流信息 |
| 3 | 已完成 | 无 | 确认收货后锁定,可做评价 |
| 4 | 已取消 | 无 | 用户取消或超时关闭 |
状态流转必须遵从「前一步未完成,后一步不可操作」。具体实现时,为每个流转动作写一个带前置状态条件的方法:确认收货时,只更新 status = 2 的订单为 3,如果影响行数为 0,说明订单状态不对,直接抛异常。这比先把订单查出来、在代码里 if 判断要安全得多,因为查询和更新之间存在时间窗。
public boolean casUpdateStatus(String orderNo, Integer fromStatus, Integer toStatus) { UpdateWrapper<Orders> uw = new UpdateWrapper<>(); uw.eq("order_no", orderNo) .eq("status", fromStatus) .set("status", toStatus); return orderMapper.update(null, uw) > 0; }逻辑说明:这个方法名取自 CAS(Compare And Swap)思想,调用方传入期望的当前状态和要变到的目标状态。比如确认收货时调用 casUpdateStatus(orderNo, 2, 3),只有当前状态确实是 2 才会更新成功,已完成的订单不会因为你多点了两次确认收货而无故改变状态。
4.3 支付回调的幂等处理
接入支付平台后,回调接口是最大的风险点。支付平台通常会对同一个支付结果多次回调,第一次处理成功后,第二次回调如果直接再处理一遍,就会重复更新、重复发货。幂等处理是必须的。
@Transactional(rollbackFor = Exception.class) public PayResult handlePayNotify(PayNotify notify) { Orders order = orderMapper.selectByOrderNo(notify.getOrderNo()); if (order == null) { return PayResult.fail("订单不存在"); } // 已支付:重复回调,直接告知成功,不重复处理 if (Integer.valueOf(1).equals(order.getStatus())) { return PayResult.success(); } // 已取消或已完成的订单不允许支付 if (order.getStatus() != 0) { return PayResult.fail("订单状态不允许支付"); } // 金额必须精确匹配,用 BigDecimal 比较 if (order.getTotalAmount().compareTo(notify.getAmount()) != 0) { return PayResult.fail("回调金额与订单金额不一致"); } int rows = casUpdateStatus(notify.getOrderNo(), 0, 1); if (rows == 1) { // 只有真正完成状态流转才更新支付时间 orderMapper.updatePayTime(notify.getOrderNo(), new Date()); } return PayResult.success(); }逻辑说明:整个方法的核心是「先查状态,再按状态分流」。状态已经是已支付,说明之前处理过,直接返回成功;状态不是待支付,说明订单已取消或已完成,不能继续支付;金额不一致直接拒绝。真正更新状态时再次用 CAS 方式,确保并发情况下只有一次能成功。
参数说明:支付回调里有个容易被忽略的点:回调金额来自第三方平台,不能直接信任前端传过来的金额。所以必须用数据库里订单金额和回调金额做 compareTo 比较,误差是绝对不允许的。如果用的是某个平台的沙箱环境,通知数据里通常有签名参数,要记得先验签再处理业务逻辑。
4.4 取消订单时的库存归还与优惠券兜底
花是损耗品,顾客取消订单是常态。取消操作有一个必须处理的副作用:下单时扣掉的库存要还回去。如果只改了订单状态,忘记恢复库存,几天后后台看到的库存数据和实际仓库就对不上,只能一笔一笔手工补。
@Transactional(rollbackFor = Exception.class) public void cancelOrder(String orderNo, Long userId) { Orders order = orderMapper.selectByOrderNo(orderNo); if (order == null || !order.getUserId().equals(userId)) { throw new BizException("无权操作该订单"); } // 只有待支付订单能取消,已发货订单要走售后流程 int rows = casUpdateStatus(orderNo, 0, 4); if (rows == 0) { throw new BizException("当前订单状态不可取消"); } orderMapper.updateCancelTime(orderNo, new Date()); // 逐条归还库存,这里要放在同一个事务里 List<OrderItem> items = orderItemMapper.selectByOrderId(order.getId()); for (OrderItem item : items) { goodsMapper.restoreStock(item.getGoodsId(), item.getQuantity()); } }逻辑说明:cancelOrder 加了 @Transactional,CAS 更新状态、写取消时间、归还库存这三步要么全部成功,要么全部回滚。如果归还库存的循环中途抛异常,订单就不会处于「已取消但库存没还」的中间状态,这是事务带给我们的最大保障。
参数说明:restoreStock 的 SQL 是 stock = stock + num,和扣库存正好相反。这里同样存在并发问题:如果用户取消订单的同时,后台店员正好在做盘点调整,两个 UPDATE 会互相覆盖。解决思路是后台盘点也走同一个 adjustStock 方法而不是直接改数据库字段。另外,如果订单用了优惠券,取消后要按规则发回一张等额券,我把这个逻辑放在 restoreStock 之后调用,保证库存优先到位、优惠券其次。
注意:超时未支付的订单也要走这套取消逻辑,而不是直接在数据库里改状态。常见做法是写一个定时任务,每分钟扫描 create_time 超过 30 分钟且状态为 0 的订单,逐条调用 cancelOrder,注意别用 delete 把订单删掉——订单是交易凭证,只能取消不能删除。
5. 花卉销售系统的 5 个高频翻车点与排查清单
这一章是我做这类系统时真实遇到过的问题,每一条都是「现象 → 原因 → 解决」的结构。你照着做可能会遇到其中两三条,提前知道能省不少排查时间。
5.1 页面价格变成 NaN,购物车总额对不上
现象:商城列表页价格正常,点进详情或加入购物车后,页面显示 NaN 或者金额对不上。
原因:后端把 BigDecimal 序列化成 JSON 后,前端直接拿来做字符串拼接;或者某个字段返回了 null,前端没做兜底直接 Number(null) 得到 0,再进行除法之类操作就变 NaN。
解决:统一后端返回结构,金额字段保证非 null,BigDecimal 序列化时保留两位小数。前端对价格字段统一走一个格式化函数,先 Number() 再 toFixed(2),不要在模板里直接拼接「¥" + price」。排查时先看浏览器 Network 里的响应体,确认后端返回的是数字还是字符串,再决定改哪边。
5.2 后台改价后,历史订单金额跟着变
现象:昨天下的订单,今天后台把商品价格改了,订单详情页和历史对账数据全变了。
原因:订单明细表里没有存「下单时的单价」,订单详情接口实时去商品表查价格。这是第 2 章强调过的价格快照问题,几乎每个新手都会踩。
解决:order_item 表的 price 字段就是为此设计的,创建订单时把商品当前售价写入明细,之后商品表怎么改都不影响历史订单。如果已经做错的系统,需要写一次性修复脚本:从商品表回填历史订单明细的 price,然后代码强制改为读明细价格。对账功能强烈依赖这个字段,千万别偷懒。
5.3 库存成负数,或补货成功后显示还是没货
现象:低库存爆款商品短时间内被大量下单,后台看到库存变成负数;另一种情况是店员手动补了库存,前台仍然提示无货。
原因:第一种是扣库存没用原子 UPDATE,两个请求同时读到旧库存;第二种是补货操作写了独立 UPDATE 语句,但和某个订单的取消回滚发生了互相覆盖,或者补货写到了另一条商品记录上。
解决:扣库存统一走 4.1 的 tryReduceStock 方法,条件里带 stock >= num。补货操作复用 3.3 的 adjustStock 方法,不要新写一条「先 select 后 update」的代码。在数据库层面可以把 stock 字段加一个 CHECK (stock >= 0) 约束,作为最后一道防线;但注意 CHECK 在部分版本 MySQL 中不生效,最可靠的办法还是代码层原子更新。
5.4 下单时间与本地差 8 小时,时分秒直接丢失
现象:数据库里存的下单时间是凌晨 4 点,页面显示却是中午 12 点;或者 create_time 只有日期没有时分秒。
原因:JDBC 连接串没写 serverTimezone,MySQL 驱动用了默认时区;另一种情况是实体类里 Date 字段没有加 @JsonFormat,前端拿到的是一串时间戳或格式不对的字符串。
解决:JDBC URL 里显式加上 serverTimezone=Asia/Shanghai,再确认 MySQL 服务器时区是 UTC+8。实体类字段统一用 java.util.Date 或 LocalDateTime,并在 getter 上配 @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")。如果你用了 Jackson,也可以全局配置时间格式化,一劳永逸。排查时先连数据库执行 SELECT NOW(),看数据库本机时间对不对,再逐层查代码。
5.5 上传的花束图片 404,后台明明能看到文件
现象:后台管理系统里上传商品图成功,但商城页面和详情页图片全部裂开,浏览器控制台报 404。
原因:上传文件写了本地磁盘绝对路径(比如 D:/upload/xxx.jpg),前端页面 URL 是 http://localhost:8080/xxx.jpg,请求根本没到磁盘目录上。缺少静态资源映射,或者 Controller 没做文件访问接口。
解决:用 Spring Boot 注册静态资源映射,把 /upload/** 映射到本地上传目录;前端一律使用相对开头的 URL「/upload/xxx.jpg」,不要存绝对路径。还有个小坑:Linux 部署时目录权限不对,图片上传成功但读取被拒绝,检查上传目录的读写权限。
6. 从「能跑」到「能演示」:验收路径、样例数据与三个进阶方向
系统做好了,最后一步是让它「打开就能演示」。很多人败在这一步:代码没问题,但没有数据可看,评委或店长打开首页是空的,瞬间没印象。所以我把这部分单独列出来讲。
6.1 用一份样例数据脚本让系统「打开就能讲」
准备一份 data.sql,包含 4 个分类、15 条商品、1 个店员账号、2 个顾客账号、若干条已完成和待支付订单。商品数据要贴近真实花店:名称带规格,价格有梯度,部分商品设置划线价,库存参差不齐,场景标签覆盖送恋人、送长辈、开业等。
INSERT INTO flower_category (id, parent_id, name, sort_order) VALUES (1, 0, '鲜切花', 1), (2, 0, '花束', 2), (3, 0, '绿植盆栽', 3), (4, 0, '开业花篮', 4); INSERT INTO flower_goods (category_id, name, price, original_price, stock, shelf_status, scene_tag, description) VALUES (2, '雪山玫瑰 33 枝', 199.00, 259.00, 50, 1, '送恋人', '白色系玫瑰,适合表白、纪念日与道歉场景'), (3, '龟背竹盆栽', 49.90, 69.00, 30, 1, '乔迁送礼', '好养耐阴,适合新家、办公室与开业布置'), (4, '锦绣开业花篮', 299.00, NULL, 10, 1, '开业庆典', '双排花架,含红掌与百合,可加贺联');逻辑说明:样例数据故意制造「可讲的故事」——一条高库存爆款、一条低库存限量款、一条划线价商品。演示时可以点开低库存商品下单,正好展示 4.1 的库存校验;也可以打开未支付订单,演示 4.4 的取消回滚。数据之间要能串起来,而不是各管各的。
6.2 快速验收清单
| 验收项 | 操作 | 预期结果 |
|---|---|---|
| 登录鉴权 | 未登录访问购物车页 | 跳转登录页,AJAX 返回 401 |
| 角色权限 | 顾客访问 /admin | 返回 403 或重定向 |
| 商品搜索 | 搜索「玫瑰」 | 返回相关商品,不出现下架商品 |
| 购物车合并 | 同一商品加两次 | 购物车只有一条,数量相加 |
| 下单扣库存 | 库存为 1 的商品下单后 | 商品详情页库存变 0,二次购买提示库存不足 |
| 订单状态 | 支付回调连续模拟两次 | 订单只从待支付变为已支付一次 |
| 取消回滚 | 取消未支付订单 | 库存恢复为下单前数量,状态变为已取消 |
这份清单可以用在答辩前自测,也可以直接作为门店上线的验收标准。
6.3 值得继续投入的三个方向:花材损耗、物流与支付
如果这套系统真正要长期用,我会优先做三件事。第一是花材损耗预警:给商品表加进货日期和保质天数,写一个定时任务,每天扫描即将过保质期的商品,在后台首页提醒店员优先促销,对花店来说,减少损耗比提高销量更直接。第二是物流单号回填与短信通知:在已发货状态增加物流公司、运单号字段,调用快递查询接口展示轨迹,顾客在订单详情就能看到,减少客服咨询量。第三是支付回调对接真实渠道:第 4 章的幂等回调代码直接对接支付平台沙箱,把金额校验、签名验证跑通,这套流程在简历里是很扎实的亮点。
我第一次做这类系统时,因为订单明细没做价格快照,被一位店主一句话问住:「跨年那天的单子,你现在把花改了价,回头对账怎么对?」这个教训让我后来每个订单模块都先画状态机再写代码。如果你也在做花卉销售系统,建议先把订单这层想明白再动手,后面会省掉很多反复。希望帮到你。
本文还有配套的精品资源,点击获取