基于Java的花店管理系统:从数据库设计到Spring Boot实战
2026/9/16 14:10:27 网站建设 项目流程

简介:这套基于 Java 的花店管理系统源码与数据库包,面向 JavaWeb 初学者及课程设计人群,可用于快速搭建包含前台展示与后台管理的花店业务原型。压缩包共 98 个文件,以 27 个 Java 源文件、16 个 JSP 页面及 CSS、JS 等前端资源为主,并配有 21 张 jpg 示例图片辅助页面效果预览、数据库相关 SQL 及 XML 配置文件,整体约 2.56MB,结构较完整。项目涵盖登录鉴权、用户管理、订单管理、花材数据维护等典型模块,前台页面与后台管理分离,Java 类负责业务逻辑,JSP/CSS/JS 负责页面展示与交互,源码分层清晰,便于理解 Servlet/JSP 开发流程;同时附带数据库文件与项目配置文件,下载后即可导入 IDE 运行调试,适合毕业设计、课程实训或入门实战参考。目前已有 636 人学习与下载,是快速上手店铺管理类项目的典型实用案例。

1. 别急着解压运行:这份 Java 花店管理系统源码到底在讲什么

拿到「基于java的花店管理系统源码+数据库.zip」这类压缩包,第一反应通常是解压、导入 IDEA、配好数据库、点运行。其实这份源码真正的价值,是把一套典型 Java Web 全栈流程压缩在「卖花」这个场景里:前台下单、后台管库、订单流转、客户档案。花店看着简单,订单、库存和花材批次之间的联动却恰好覆盖了增删改查之外的事务、外键和统计问题,这也是数据库课程设计常拿它当练手对象的原因。

对刚过完 java 基础语法的人,它补上了从配置到部署的完整链路;对做数据库课程设计的学生,现成的 ER 图和 SQL 脚本是很好的起步材料;对老手,真正值得看的是库存扣减和订单状态如何落表。后面按「先定表结构和环境、再写业务、最后加固」的顺序展开,这也是此类管理系统最常见的开发顺序,解压后按这个顺序走,能少踩一半的坑。

2. 选型与表设计:花店管理系统的数据库先行

在一套 Java 花店管理系统里,技术栈的选型决定了 zip 解压后你看到的是 War 包工程还是 Spring Boot 工程。早年课程设计常见 JSP + Servlet + JDBC + Tomcat 的搭配,优点是每行代码都能看见请求怎么进来、连接怎么打开;近五年则基本转向 Spring Boot + MyBatis + MySQL,原因很实际:配置收敛到 yml、SQL 与 Java 分离、单体应用够用且配套资料最多。如果你拿到的是老式 JSP 工程,不要急着换框架,先把数据库脚本导入,确认表结构合理,再决定是否用 MyBatis 重写数据访问层。

2.1 花店核心实体拆分:从花材到订单的六张表

凡是涉及「销售 + 库存」的系统,表设计的第一原则是「订单与库存分离,金额与数量分离」。花店管理系统最少需要六张业务表:花材表(flower)、花材分类表(category)、库存表(stock)、客户表(customer)、订单主表(orders)、订单明细表(order_item)。再加一张供应商表(supplier)和采购表(purchase)就能支持进货场景。企业里常见的设计还会拆出花束组合表,因为「一束花 = 多枝花 + 包装耗材」,这里先不展开,避免把表结构撑得太散。

其中最容易出问题的是库存表的设计。很多学生会把库存数量直接放在花材表里,下单就UPDATE flower SET stock = stock - 1。这种做法在只有一台机器、没有并发的时候没问题,一旦两个订单同时买同一款花,后提交的更新会覆盖先提交的数量,库存就变成负数。标准做法是单独建stock表,按flower_id一对一定义quantitysafety_stock,并在更新时用带条件的 UPDATE 来做减法控制。

订单主表和订单明细表的拆分是「一主多从」的经典结构。主表存订单号、客户 ID、订单状态、总金额、创建时间;明细表存每一行花材的 ID、单价、数量、小计金额。注意明细表必须冗余「单价快照」,不能下单后去关联花材表的当前售价——花店促销频繁,两个月前的订单明细如果跟着花材表变价,对账就没法做了。

2.1.1 六张表的 MySQL 建表脚本

以下脚本使用 MySQL 8.0,存储引擎 InnoDB,字符集 utf8mb4。utf8mb4 比 utf8 多覆盖了 emoji 字符,花店备注字段里客户经常发 🎂🌸 这类符号,用 utf8 会直接报错或乱码,这是数据库课程设计里最高频的坑之一。

-- 花材分类表 CREATE TABLE category ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '分类ID', name VARCHAR(50) NOT NULL COMMENT '分类名称', parent_id BIGINT DEFAULT NULL COMMENT '父分类ID,0表示顶级', sort_order INT DEFAULT 0 COMMENT '排序值,越小越靠前', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_parent (parent_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='花材分类表'; -- 花材表 CREATE TABLE flower ( id BIGINT NOT NULL AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT '花材名称', category_id BIGINT NOT NULL COMMENT '所属分类', unit VARCHAR(10) DEFAULT '枝' COMMENT '销售单位', price DECIMAL(10,2) NOT NULL COMMENT '当前售价', cost_price DECIMAL(10,2) NOT NULL COMMENT '成本价', image_url VARCHAR(255) DEFAULT NULL, status TINYINT DEFAULT 1 COMMENT '1上架 0下架', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_category (category_id), CONSTRAINT fk_flower_category FOREIGN KEY (category_id) REFERENCES category(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='花材表';

逻辑说明:花材表通过category_id外键关联分类表,pricecost_price都用DECIMAL而不是FLOATFLOAT是近似存储,做总价累计时会出现 0.1 加 0.2 不等于 0.3 的情况,财务相关字段一律用DECIMAL(10,2),这是 Java 后端拿到BigDecimal而不是Double的根本原因。

-- 库存表 CREATE TABLE stock ( flower_id BIGINT NOT NULL COMMENT '花材ID', quantity INT NOT NULL DEFAULT 0 COMMENT '当前可用库存', safety_stock INT NOT NULL DEFAULT 0 COMMENT '安全库存', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (flower_id), CONSTRAINT fk_stock_flower FOREIGN KEY (flower_id) REFERENCES flower(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存表'; -- 客户表 CREATE TABLE customer ( id BIGINT NOT NULL AUTO_INCREMENT, name VARCHAR(50) NOT NULL, phone VARCHAR(20) NOT NULL COMMENT '手机号,登录账号', password VARCHAR(100) NOT NULL COMMENT 'BCrypt密文', address VARCHAR(200) DEFAULT NULL, level TINYINT DEFAULT 1 COMMENT '会员等级', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_phone (phone) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户表'; -- 订单主表 CREATE TABLE orders ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT '业务订单号,唯一', customer_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待付款 1已付款 2配送中 3已完成 4已取消', remark VARCHAR(255) DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_customer (customer_id), CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customer(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表'; -- 订单明细表 CREATE TABLE order_item ( id BIGINT NOT NULL AUTO_INCREMENT, order_id BIGINT NOT NULL, flower_id BIGINT NOT NULL, item_name VARCHAR(100) NOT NULL COMMENT '商品名称快照', price DECIMAL(10,2) NOT NULL COMMENT '下单时单价快照', quantity INT NOT NULL, subtotal DECIMAL(10,2) NOT NULL, PRIMARY KEY (id), KEY idx_order (order_id), CONSTRAINT fk_item_order FOREIGN KEY (order_id) REFERENCES orders(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';

参数说明:order_no用唯一索引而不是主键,因为订单号是业务号,可能包含门店编码和日期,例如SH20250521001;主键自增 ID 只对系统内部可见。order_item.item_nameprice是冗余快照字段,它们的值来自下单那一刻的花材信息,之后花材改名、调价都不影响历史订单。这六张表的关系就是一张标准 ER 图:categoryflower是 1 对 N,flowerstock是 1 对 1,customerorders是 1 对 N,ordersorder_item是 1 对 N。

表名关键字段是否冗余快照说明
categoryname, parent_id支持二级分类
flowerprice, cost_price, status售价随促销变动
stockquantity, safety_stock与 flower 一对一
customerphone, password, level手机号唯一
orderstotal_amount, status主表金额可重算状态用数字
order_itemitem_name, price下单时快照,不受调价影响

2.2 为什么订单状态不用枚举字段直接存字符串

订单状态这里用了 TINYINT 0-4。成长期的项目会倾向用 VARCHAR 存PAIDSHIPPING这类语义化字符串,便于日志里直接看懂。但花店管理系统这种规模,用数字状态位的好处是status字段的排序、分组和索引都很轻,配合 Java 端的枚举类就能兼顾可读性。

public enum OrderStatus { UNPAID(0, "待付款"), PAID(1, "已付款"), DELIVERING(2, "配送中"), FINISHED(3, "已完成"), CANCELLED(4, "已取消"); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } public String getDesc() { return desc; } public static OrderStatus of(int code) { for (OrderStatus s : values()) { if (s.code == code) return s; } throw new IllegalArgumentException("未知订单状态: " + code); } }

逻辑说明:数据库存0,Java 层通过OrderStatus.of(0)取出枚举再拿desc渲染页面,既不影响 SQL 的WHERE status = 1查询,也不会在代码里散落魔法数字。状态流转的合法性建议单独写校验,不允许从「待付款」直接跳到「已完成」,这类状态机校验是 java 面试题里经常追问的点,源码里如果没实现可以自己补上。

2.3 供应商与采购表:别把花材表当成万能表

在订单明细冗余了单价快照之后,另一个常见问题是把供应商和采购信息全部塞进花材表。一个花店可能有三个供应商都在供玫瑰,各自价格不同、到货批次不同。如果在flower表加supplier_idpurchase_price两个字段,每次进货都要 UPDATE 花材行,不但覆盖了历史采购价,还让「这个月从谁家进了多少货」完全查不出来。

正确做法是再加供应商表supplier(id, name, contact, phone)和采购表purchase(id, supplier_id, flower_id, quantity, price, purchase_time)。采购表每进一次货插一行,price存这一批的实际进价,花材表里的cost_price只是用于毛利估算的参考值。这套设计在花店管理系统里可能用不到,但它是「业务对象」和「业务行为」分离的典型例子:花材是静态档案,采购是动态行为,静态档案和动态行为混在一张表里,半年后对账必出问题。

到这里 zip 里的数据库脚本应该能看懂了。下一步先把这套结构在本地真正运行起来,再去读业务代码,顺序反了容易陷入「看半天没有任何反馈」的境地。

3. 本地跑通:从 zip 到能打开的花店管理系统(Java 环境与数据库初始化)

拿到源码包后先别双击运行。花店管理系统这类 Java Web 项目绝大多数跑不起来,不是因为代码错,而是环境三件套不对齐:JDK 主版本、Maven 依赖仓库、MySQL 的字符集和时区。先确认 Java 版本和构建工具版本,再导数据库,最后改配置启动,每一步都能得到即时反馈,出了问题也知道是哪一层。

3.1 JDK、Maven 与 MySQL 版本对齐检查

打开解压目录,先找三个文件:pom.xml(Maven 工程)或build.gradlesrc/main/resources/application.ymlapplication.properties,以及sql/db/目录下的.sql脚本。在没有pom.xml的时候,要认命这可能是 JSP + Servlet 老工程,运行方式会变成部署 War 包到 Tomcat,这里先按 Spring Boot 工程讲。

pom.xml里看<java.version>spring-boot-starter-parent的版本。常见组合是 Java 8 配 Spring Boot 2.x、Java 17 配 Spring Boot 3.x。Spring Boot 3 把javax.*包换成jakarta.*,如果你的 JDK 是 17 却硬跑 Boot 2 的源码,编译期会报程序包 javax.servlet 不存在

java -version mvn -v mysql --version

逻辑说明:这三条命令分别确认 JDK、Maven、MySQL 是否在 PATH 里。mvn -v输出里会带上它使用的 Java 版本,如果 Maven 用的是 JDK 8 而工程要求 17,可以在 IDEA 的 Settings 里把 Project SDK 和 Maven Runner 的 JDK 统一。MySQL 版本直接决定建立连接时用不用useSSL=false参数,8.0 默认开 SSL 认证,老驱动连 5.7 没这个问题。

3.1.1 初始化数据库的两种方式

方式一:命令行导入。先创建数据库,再指定字符集导入脚本。

mysql -uroot -p -e "CREATE DATABASE flower_shop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" mysql -uroot -p flower_shop < sql/flower_shop.sql

逻辑说明:第一行建库时把utf8mb4和排序规则写死,避免 MySQL 默认的latin1导致中文乱码;第二行把 SQL 脚本导入。如果脚本里有CREATE DATABASE语句,导入前要确认库名是否和你后续配置里的连接 URL 一致,不一致可以直接改 URL,不必重导。导入完成后执行SHOW TABLES;应该能看到六张以上业务表。

方式二:用 Navicat 或 IDEA 自带的 Database 面板图形化导入。Navicat 里右键「flower_shop」选「运行 SQL 文件」,把脚本拖进去执行。对新手来说图形化更容易定位到具体哪一行 SQL 报错,但注意 Navicat 默认会把DELIMITER相关的存储过程脚本处理得和你预期不完全一致,纯表结构脚本没这个问题。图形化工具只负责执行,真正决定编码的还是建库语句里的 CHARSET。

3.2 改 application.yml 的三个关键参数:连接串、账号密码、时区

Spring Boot 工程启动前最必要的改动在application.ymlapplication.properties里。花店系统的源码只要改对数据源这四个参数,大概率能直接启起来。

spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/flower_shop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mvc: hiddenmethod: filter: enabled: true

参数说明:serverTimezone=Asia/Shanghai解决 MySQL 8.0 与服务端时区不一致导致的 8 小时偏移问题;allowPublicKeyRetrieval=true配合useSSL=false使用,否则新版 MySQL 驱动用caching_sha2_password认证时连接会报Public Key Retrieval is not allowedcharacterEncoding=utf8这里虽然写的是utf8,MySQL 驱动会按utf8mb4处理,真正决定字符集的是数据库本身的 CHARSET。hiddenmethod.filter.enabled是为了让表单能提交 PUT/DELETE 请求,很多旧系统的增删改查页面依赖这个过滤器,少了它编辑花材时总是 405。

改完后执行:

mvn spring-boot:run

启动日志里看到Tomcat started on port(s): 8080就算成功。如果端口被占用,在application.yml里加server.port: 8081换端口。启动失败的常见顺序是:先报数据库连不上,排除 URL 和密码;再报Table 'flower_shop.xxx' doesn't exist,说明脚本没导入成功;最后报Failed to configure a DataSource,说明pom.xml里少了mybatis-spring-boot-starter或 JDBC 驱动的依赖。

3.3 MyBatis 的 mapper 路径与驼峰映射

Spring Boot + MyBatis 最常见的启动报错是Invalid bound statement (not found)。原因是接口和 XML 文件没有配对。花店系统里通常有com.example.mapper.FlowerMapper接口和resources/mapper/FlowerMapper.xml。确保 yml 里配置了:

mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.entity configuration: map-underscore-to-camel-case: true

参数说明:mapper-locations告诉 MyBatis 去resources/mapper目录下找 XML 文件;map-underscore-to-camel-case把数据库的create_time自动映射成 Java 属性createTime,不配置的话查询结果里createTime全是 null,这是老手也经常栽的隐性 bug。type-aliases-package让 XML 里可以直接写resultType="Flower"而不必写全限定类名。这三行配置能解决花店管理系统八成以上的查询异常。

3.4 启动期报错排查清单

报错内容直接原因处理方式
Public Key Retrieval is not allowedMySQL 8 使用 caching_sha2_passwordURL 加allowPublicKeyRetrieval=trueuseSSL=false
Invalid bound statement (not found)Mapper 接口和 XML 未配对检查 mapper-locations 路径和 XML 里 namespace
Table 'flower_shop.xxx' doesn't exist脚本未导入或连错库确认库名和 yml URL,用 SHOW TABLES 验证
Access denied for user 'root'@'localhost'密码或 host 不对检查 yml 账号密码,确认 MySQL 用户表的 host
Port 8080 was already in use端口冲突server.port,或杀掉占用进程
程序包 javax.servlet 不存在Spring Boot 3 使用 jakarta 包名换 JDK 17 与 Boot 3 匹配,或降级 Boot 2

这张表基本覆盖了课程设计答辩前最容易预见的启动问题。如果你读的是 MyBatis 源码,会知道上面的mapper-locations本身就是在构造XMLMapperBuilder时解析出来的资源路径数组,路径写错时异常信息往往隐藏在「nested exception」里,别只看最上面一行。

4. 核心业务实现:库存扣减、下单与统计(Java 代码实战)

跑通之后,真正决定一个花店管理系统能不能用的,是三个人人都会问的业务点:下单时库存怎么扣、总金额怎么算、月度报表怎么出。这一章同步给后端代码和 SQL,你可以直接对照源码里对得上的那部分。

4.1 下单主流程:单方法内完成校验、扣库存、写订单

下单的核心不是「INSERT 订单」,而是「校验库存 + 扣减 + 生成主单和明细」必须在一个事务里完成。任何一步失败,之前写的订单都要回滚。Spring 的做法是在 Service 方法上加@Transactional

@Service public class OrderService { private final OrderMapper orderMapper; private final OrderItemMapper orderItemMapper; private final StockMapper stockMapper; public OrderService(OrderMapper orderMapper, OrderItemMapper orderItemMapper, StockMapper stockMapper) { this.orderMapper = orderMapper; this.orderItemMapper = orderItemMapper; this.stockMapper = stockMapper; } @Transactional(rollbackFor = Exception.class) public String createOrder(Long customerId, List<CartItem> items, String remark) { for (CartItem item : items) { int updated = stockMapper.deductStock(item.getFlowerId(), item.getQuantity()); if (updated == 0) { throw new BizException("库存不足或商品已下架: " + item.getFlowerId()); } } Order order = new Order(); order.setOrderNo(OrderNoGenerator.next("SH")); order.setCustomerId(customerId); order.setStatus(OrderStatus.UNPAID.getCode()); order.setRemark(remark); order.setTotalAmount(calculateTotal(items)); orderMapper.insert(order); for (CartItem item : items) { OrderItem oi = buildOrderItem(order.getId(), item); orderItemMapper.insert(oi); } return order.getOrderNo(); } }

逻辑说明:业务先扣库存、再写订单主表、最后写明细,顺序不能反过来,否则明细写成功但库存扣减失败时,整个事务虽然回滚了,日志里却会多出一次无意义的扣减尝试。stockMapper.deductStock返回受影响行数,返回 0 就意味着库存不够,这是一个原子操作,下面看它的 SQL。注意事务里不要写远程调用和耗时的外部 IO,锁会一直持有到方法结束,下单接口超时往往会拖垮整个库。

4.1.1 防超卖的 UPDATE 语句写法
<update id="deductStock"> UPDATE stock SET quantity = quantity - #{quantity}, update_time = NOW() WHERE flower_id = #{flowerId} AND quantity >= #{quantity} </update>

逻辑说明:关键在WHERE quantity >= #{quantity}这个条件。UPDATE 在 InnoDB 里默认对命中的行加排他锁,两个并发请求同时改同一行时,后到的会等前一个提交或回滚后再执行,此时quantity已被减过,条件不满足自然返回 0。这种「条件更新」比「先 SELECT 后 UPDATE」在并发下安全得多,也避免了对 SELECT 加悲观锁带来的死锁风险。若要更精细,还可以在flower表加version字段做乐观锁,但对花店这种高并发并不存在的系统,条件更新已经足够。

4.2 花店销售统计:按天、按月聚合的 SQL 写法

统计模块是花店管理系统里最能看出 SQL 水平的部分。最朴素的写法是把所有订单查出来在 Java 里循环累加,数据量到几千条就开始卡。正确做法是让 MySQL 完成聚合,只回传结果集。

-- 按月统计销售额和订单数,不含已取消订单 SELECT DATE_FORMAT(create_time, '%Y-%m') AS month, COUNT(*) AS order_count, SUM(total_amount) AS sales_amount FROM orders WHERE status != 4 AND create_time >= '2025-01-01 00:00:00' GROUP BY DATE_FORMAT(create_time, '%Y-%m') ORDER BY month;

参数说明:DATE_FORMAT(create_time, '%Y-%m')对创建时间做按月截断,让 1 号到 31 号的订单归到同一组;GROUP BY的字段要和 SELECT 里的表达式完全一致,MySQL 的 ONLY_FULL_GROUP_BY 模式下写GROUP BY month这种别名会报错;status != 4排除取消单。如果还想看每一天的销量排行,可以再写一条按flower_id分组、按销量倒序的明细聚合。

聚合维度关键函数典型场景
按天DATE_FORMAT(create_time, '%Y-%m-%d')每日销售日报
按月DATE_FORMAT(create_time, '%Y-%m')月度对账
按花材flower_id 分组 + SUM(quantity)畅销排行与进货参考
按客户customer_id 分组 + COUNT(*)会员复购分析

4.3 分页查询:PageHelper 的参数边界

花材列表和管理端订单列表都要分页。常见做法是用 MyBatis 的分页插件 PageHelper,但它有一个人尽皆知的坑:PageHelper.startPage()后面必须紧跟第一条查询语句,中间不能有别的 SQL 或逻辑判断。

public PageInfo<Flower> pageFlower(String keyword, int pageNum, int pageSize) { PageHelper.startPage(pageNum, pageSize); List<Flower> list = flowerMapper.queryByKeyword(keyword); return new PageInfo<>(list); }

逻辑说明:startPage会修改 ThreadLocal 里的分页参数,MyBatis 执行下一条查询时自动追加LIMIT,查询完成后清理。如果你在startPagequeryByKeyword之间做了日志查询或其他 Mapper 调用,分页参数可能作用到错误的查询上。pageSize也要做上限保护,超过 100 强制截断,避免有人恶意传pageSize=999999直接拖全表。

提示:分页参数 ThreadLocal 的清理依赖 MyBatis 插件拦截器,如果配置了多个拦截器,注意PageInterceptor的执行顺序要排在自定义拦截器之后,否则分页 SQL 可能生成错误。

还有一个老生常谈的细节:分页查询的 count 语句在数据量大时会单独执行一次SELECT COUNT(*),花店系统里如果订单表有几十万行,这个 count 会成为慢查询。可以在queryByKeyword的 XML 里用resultType="long"的独立 count 查询,或者接受 PageHelper 自动生成的 count,前提是 where 条件上有合适索引。

4.4 订单取消与库存回补:别在 Service 里散落 UPDATE

订单取消是花店管理系统里非常高频的操作,客户下单后悔、花材当天售罄,都要走取消流程。如果每个 Controller 里都写一次stockMapper.rollbackStock,时间一长各种状态遗漏就会冒出来。正确做法是把「取消订单」收敛成 Service 里的一个方法,先校验当前状态,再回补库存,再更新状态,同样在一个事务里。

@Transactional(rollbackFor = Exception.class) public void cancelOrder(Long orderId) { Order order = orderMapper.selectById(orderId); if (order == null || order.getStatus() != OrderStatus.UNPAID.getCode()) { throw new BizException("当前状态不可取消"); } List<OrderItem> items = orderItemMapper.selectByOrderId(orderId); for (OrderItem item : items) { stockMapper.increaseStock(item.getFlowerId(), item.getQuantity()); } orderMapper.updateStatus(orderId, OrderStatus.CANCELLED.getCode()); }

逻辑说明:先查状态再回补库存,回补用的是quantity = quantity + #{quantity},天然幂等,配合 UPDATE 的影响行数可以排查重复取消。取消已发货订单时,状态要从「配送中」流转到「已取消」可能需要财务确认,这类复杂状态机在花店场景里通常不实现,但代码结构上给它留一个独立方法,后面要加审核流程时改动最小。

5. 进阶验证与加固:让花店系统从「能跑」到「能交付」

到这里系统能跑、能下单、能出报表。但如果这份源码要作为课程设计答辩或入职作品展示,还有三个层面的工作值得做:验证、安全、可维护性。

5.1 五分钟冒烟测试:下单、扣库存、回滚各验证一次

冒烟测试三个用例:正常下单、库存不足、订单取消后库存回补。

# 查看某花材初始库存 mysql -uroot -p flower_shop -e "SELECT flower_id, quantity FROM stock WHERE flower_id = 1;" # 通过接口下单 curl -X POST http://localhost:8080/api/order \ -H "Content-Type: application/json" \ -d '{"customerId":1,"items":[{"flowerId":1,"quantity":2}]}'

验证逻辑:下单后再次查库存,quantity应减少 2,同时orders表和order_item表各新增一条记录。接着把quantity传到超过当前库存的数,接口应返回业务异常而不是 500 错误,并且数据库里没有残留的订单半成品。库存不足时的回滚验证最能体现@Transactional是否生效:如果发现主表插入了订单但明细表是空的,说明事务没生效或 MyBatis 插入用了 autocommit。

5.2 三个安全加固点:参数化查询、密码存储、越权校验

第一,所有 Mapper XML 里的${}都要改成#{}${}直接拼接字符串,#{userName}预编译成占位符,可以防 SQL 注入。花店管理系统的登录查询如果写成WHERE phone = '${phone}',输入' OR '1'='1就能绕过密码校验,这是最容易被答辩老师点名的漏洞。

第二,客户密码不能明文存。数据库脚本里password字段长度 100,就是给 BCrypt 密文准备的。登录校验用BCryptPasswordEncoder.matches(rawPassword, encodedPassword),不要自己写 MD5。

String rawPassword = "flower123"; String encoded = new BCryptPasswordEncoder().encode(rawPassword); boolean ok = new BCryptPasswordEncoder().matches(rawPassword, encoded);

第三,后台管理接口要做登录状态校验,不能只靠前端按钮隐藏。实际项目里一般用拦截器或 Spring Security 拦截/api/admin/**路径,没带 token 或 session 的直接返回 401。花店这种场景不需要上 OAuth 2.0,一个基于 session 的拦截器就够。

5.3 库表大小与索引:给订单表加两个最常用的复合索引

订单列表页最常用的查询条件是「客户 + 时间」和「状态 + 时间」。orders表上除了主键和uk_order_no,建议再补两个复合索引:

ALTER TABLE orders ADD INDEX idx_customer_time (customer_id, create_time); ALTER TABLE orders ADD INDEX idx_status_time (status, create_time);

逻辑说明:idx_customer_time支持「查某个客户的历史订单」并按时间倒序;idx_status_time支持「按状态筛选待处理订单」。为什么不能只建单列索引?因为 MySQL 8.0 里WHERE customer_id = ? ORDER BY create_time DESC用复合索引可以直接走索引排序,避免 filesort。索引不是越多越好,订单表的索引控制在 5 个以内,写多读少的业务更要注意更新索引的成本。

最后给一个通用技巧:理解一份花店管理系统源码,关键是先画 ER 图再读代码。把六张表的关系画出来,昨天看不懂的 Service 代码今天大概率能看懂——因为所有逻辑都在围着这些表转。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询