恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
花卉销售系统开发实践:订单、库存与支付全流程解析
首页
资讯中心
/
花卉销售系统开发实践:订单、库存与支付全流程解析
花卉销售系统开发实践:订单、库存与支付全流程解析
发布时间:2026/10/12 2:48:50
简介这是一份面向花卉贸易行业的管理系统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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 同级排序越小越靠前 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 小计 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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;charsetUTF-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) { UpdateWrapperFlowerGoods 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 LambdaQueryWrapperCartItem() .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) { UpdateWrapperFlowerGoods 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) { UpdateWrapperOrders uw new UpdateWrapper(); uw.eq(order_no, orderNo) .eq(status, fromStatus) .set(status, toStatus); return orderMapper.update(null, uw) 0; }逻辑说明这个方法名取自 CASCompare 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()); // 逐条归还库存这里要放在同一个事务里 ListOrderItem items orderItemMapper.selectByOrderId(order.getId()); for (OrderItem item : items) { goodsMapper.restoreStock(item.getGoodsId(), item.getQuantity()); } }逻辑说明cancelOrder 加了 TransactionalCAS 更新状态、写取消时间、归还库存这三步要么全部成功要么全部回滚。如果归还库存的循环中途抛异常订单就不会处于「已取消但库存没还」的中间状态这是事务带给我们的最大保障。参数说明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。解决统一后端返回结构金额字段保证非 nullBigDecimal 序列化时保留两位小数。前端对价格字段统一走一个格式化函数先 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 连接串没写 serverTimezoneMySQL 驱动用了默认时区另一种情况是实体类里 Date 字段没有加 JsonFormat前端拿到的是一串时间戳或格式不对的字符串。解决JDBC URL 里显式加上 serverTimezoneAsia/Shanghai再确认 MySQL 服务器时区是 UTC8。实体类字段统一用 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 章的幂等回调代码直接对接支付平台沙箱把金额校验、签名验证跑通这套流程在简历里是很扎实的亮点。我第一次做这类系统时因为订单明细没做价格快照被一位店主一句话问住「跨年那天的单子你现在把花改了价回头对账怎么对」这个教训让我后来每个订单模块都先画状态机再写代码。如果你也在做花卉销售系统建议先把订单这层想明白再动手后面会省掉很多反复。希望帮到你。本文还有配套的精品资源点击获取