毕业设计做水果管理系统,很多人第一步就选错了方向。当年我自己做这个题目的时候,网上能找到的资料不是太老就是太碎,照着折腾半个月,连库存扣减都整不明白。后来踩了一圈坑,重新梳理了需求和技术方案,才把整个系统从数据库到前端页面完整跑通。这篇就用我当时做完这个Spring Boot水果销售与库存管理系统的经历,把整个项目的设计思路、核心表结构、关键业务逻辑、权限控制和部署细节全部摊开来讲,代码能贴就贴,逻辑能拆就拆,给正在做类似选题的同学一份真正能“抄作业”的参考。这个系统要解决的问题很具体:一家水果店,需要管水果的进销存、会员和销售数据。放在技术术语里就是一套标准的单商户进销存系统,核心难点不在功能多,而在库存与销售之间的联动、数据一致性和报表统计。适合参考的人群也很明确:正在做Spring Boot管理类毕设的学生,以及刚入门想搞懂一个完整业务系统怎么落地的人。1. 项目整体设计与模块拆解1.1 为什么选Spring Boot而不是其他框架先说选型。现在做管理类毕设,Spring Boot几乎是最稳妥的选择,没有之一。它的核心价值在于“自动配置”和“起步依赖”这两个机制,让开发者把精力集中在业务逻辑上,而不是消耗在繁琐的XML配置和框架整合中。比如你需要操作数据库,引入一个spring-boot-starter-data-jpa或者mybatis-plus-boot-starter依赖,剩下的数据源、事务管理器、ORM映射框架基本都被自动配置好了。这和以前写SSM(Spring + Spring MVC + MyBatis)的体验完全不同。SSM时代,光spring-context.xml、spring-mvc.xml、mybatis-config.xml三个配置文件就能写上百行,配错一个扫描路径,项目启动直接报BeanCreationException,对刚接触框架的学生来说排查起来十分痛苦。Spring Boot把这些约定固化下来,“约定优于配置”,你用它的默认习惯写代码,几乎不需要手动声明Bean。在性能方面,很多人会担心Spring Boot太重、启动慢。这点在毕设和中小型业务场景下完全不是问题。内嵌的Tomcat让部署变成“打一个jar包丢服务器上就能跑”,没有独立Tomcat环境也能通过java -jar命令直接启动。对水果店这种日常请求量不大的场景,系统的瓶颈根本不在框架,而在并发下的数据一致性设计上。1.2 模块划分:从用户角色反推功能边界做业务系统先别急着写代码,先想清楚“谁会用到这个系统,他们分别要做什么”。我当时的系统就两类角色:管理员和店员。对管理员来说,日常关心的是三件事:水果库存够不够、哪些水果卖得好、一天的流水是多少。对应到功能就是库存管理、采购入库、销售出库、报表统计。对店员来说,核心任务就一个:结账开单。对应到功能就是收银、打印小票、登记会员。一辆车有四个轮子,这套系统也一样,无论页面怎么变,核心的六个功能模块是不会变的:商品管理:维护水果的基本信息,比如名称、产地、品类、进货价、零售价、单位、保质期。库存管理:实时展示各水果的剩余库存,支持盘点调整、库存预警。采购入库:登记供应商送货信息,录入采购数量和价格,自动增加库存。销售出库:收银开单,选择水果和数量后自动校验库存并扣减。会员管理:会员注册、积分累积、积分抵扣。数据报表:按天、按周、按月统计销售额、销量、毛利和滞销品。这六个模块不是孤立的,它们通过“库存”和“订单”这两条主线串起来。采购入库影响库存,销售出库也影响库存,而销售和采购的流水又共同产生报表数据。理解了这条数据流,整个系统的数据库设计也就清晰了。2. 数据库设计:从水果生意反推数据表2.1 核心表结构设计思路数据库设计是整个项目的灵魂。业务逻辑写错了可以改,表结构设计得不合理,后面每个功能写起来都别扭。我当时的建表思路是“拆表,不折腾”。所谓拆表,就是每个业务实体对应一张表,再通过外键和关联表把关系串起来。单商户进销存系统的核心表大概有这些:表名作用关键字段fruit_info水果基本信息id, name, category, origin, purchase_price, sale_price, unit, shelf_life, statusfruit_stock库存表id, fruit_id, quantity, warn_threshold, update_timesupplier供应商表id, name, contact_phone, addresspurchase_order采购单主表id, supplier_id, purchase_date, total_amount, statuspurchase_order_item采购单明细表id, purchase_order_id, fruit_id, quantity, price, amountsale_order销售订单主表id, order_no, member_id, order_date, total_amount, real_amount, discount_amountsale_order_item销售订单明细表id, sale_order_id, fruit_id, quantity, price, amountmember会员表id, phone, name, points, level, create_timesys_user系统用户表id, username, password, role, statuspoints_log积分流水表id, member_id, change_points, reason, create_time为什么要拆成“主表和明细表”两张表来记录一次采购或销售?原因很简单,一张订单里往往包含多种水果,如果所有数据都塞在同一行的逗号分隔字段里,统计的时候解析起来会非常痛苦。拆成主表和明细表后,订单ID关联明细表,可以精确定位“这次订单里有哪几种水果、每种多少斤、多少钱”,后续做“哪些水果卖得最好”这类统计,直接查明细表就行。2.2 库存表的版本号字段为什么必须有水果库存表fruit_stock在设计时,除了数量字段quantity和预警阈值warn_threshold,我还加了一个容易被忽略的字段:版本号version。这个字段是给乐观锁用的。什么叫乐观锁?简单说就是“我乐观地认为更新时没人来抢,所以不锁表,只在最后更新时检查一下版本号有没有变”。实际场景是这样的:两个店员同时收银,都选了一笔“红富士苹果卖出10斤”的操作,如果没有并发控制,两个事务同时读到库存100斤,各自扣10斤,最后写回时库存就只剩90斤,而实际应该剩下80斤。数据就这样悄悄错了。加了版本号之后,更新语句变成:UPDATE fruit_stock SET quantity = quantity - 10, version = version + 1 WHERE fruit_id = 1 AND version = 1;如果两个请求同时进来,第一个更新成功了,版本号从1变成2,第二个请求再去执行,发现version = 1的条件已经不满足,更新影响行数为0,就知道有人先改了库存,需要重新读取再重试。这就是用版本号解决并发扣减的经典做法。注意:库存表的主键ID加上水果ID建立唯一索引,防止同一水果出现多条库存记录,这也是我在实际开发中踩过的坑——一开始没加唯一约束,采购入库时不小心插入了两条库存记录,导致库存越看越乱。2.3 订单流水表与结算逻辑订单表我是和库存表分开设计的,当时觉得没必要,后来发现分开的好处非常大。销售订单表sale_order记录“哪笔订单、哪个会员、总金额多少”,明细表sale_order_item记录“这笔订单里具体买了什么”。一次结算的完整流程是这样的:收银员在前端勾选商品,点击结算,后端接收到订单和商品列表,先校验库存,再生成订单,最后扣减库存。订单流水可以非常清楚地记录每一笔交易,方便后续对账。这里有一个容易被忽视的设计细节:商品的价格不能直接取数据库中的实时价格,必须把成交时的价格冗余存到订单明细表里。不然水果价格调整后,历史订单里的总价会跟着变,统计出来的销售额就全错了。这个道理和“买完东西小票上写多少钱,这个价格不会因为超市第二天调价而改变”是一样的。3. 核心功能实现:从下单到扣库存的完整链路3.1 销售出库与库存扣减的原子性操作销售出库是整套系统里最核心、也最容易写错的功能。很多教程里的写法是这样的:先查库存,如果库存充足,再执行扣减SQL。这种“先查后改”的方式在并发高的时候会出问题——前面聊过的超卖。正确做法是把“检查库存”和“扣减库存”合并成一条SQL,让数据库在底层原子地完成这两个操作:@Override @Transactional(rollbackFor = Exception.class) public void createSaleOrder(SaleOrderDTO dto) { String orderNo = generateOrderNo(); SaleOrder order = new SaleOrder(); order.setOrderNo(orderNo); order.setMemberId(dto.getMemberId()); // 先计算订单总金额 BigDecimal totalAmount = BigDecimal.ZERO; for (SaleOrderItemDTO item : dto.getItems()) { FruitStock stock = fruitStockMapper.selectById(item.getFruitId()); if (stock == null) { throw new BusinessException("水果不存在"); } // 核心:条件更新,库存不足时影响行数为0 int rows = fruitStockMapper.deductStock(item.getFruitId(), item.getQuantity()); if (rows == 0) { throw new BusinessException("库存不足:" + item.getFruitName()); } // 计算明细金额 BigDecimal amount = item.getPrice().multiply(new BigDecimal(item.getQuantity())); // 组装明细对象 // ... } // 保存订单主表和明细表 }对应的Mapper SQL写成这样:<update id="deductStock"> UPDATE fruit_stock SET quantity = quantity - #{quantity} WHERE fruit_id = #{fruitId} AND quantity >= #{quantity} </update>这行SQL的意思是:只有当前库存大于等于要扣的数量才执行更新。影响的记录数是0,说明库存不够,直接抛异常,事务回滚,前面扣的其他水果也不会真正提交,整个订单取消。这里要特别注意事务的配置细节。@Transactional注解默认只对运行时异常(RuntimeException)回滚,但对于检查异常(Exception)是不会回滚的。为了保险,我在类上用了@Transactional(rollbackFor = Exception.class),这意味着无论是空指针异常还是业务自定义异常,只要抛出,整个事务就会回滚。这在生成订单这类多表写入操作里是必须的,不然可能出现“订单保存了,库存却扣了”这种两边不落好的情况。3.2 采购入库与库存联动采购入库和销售出库是一对“对称操作”:销售扣库存,采购加库存。业务逻辑上同样要处理“主表 + 明细表”的结构,然后循环把采购的水果数量累加到库存表里。采购入库还有一个库存初始化的场景。系统上线前,店铺里可能有几十斤苹果、几串香蕉已经摆在货架上了,这些库存还没录入系统,就需要通过“采购入库”的方式先录入,或者提供“期初库存录入”功能。我当时直接把期初库存也设计成一条特殊采购单,供应商字段显示“期初建账”,这样后续报表统计时,期初数据和其他采购混在一起,反而方便,不用额外开发一套逻辑。3.3 库存预警机制的实现细节库存预警是水果店老板最在意的功能之一。水果生鲜不同于工业品,库存积压会直接造成损耗,卖完了又没有及时补货,也影响营业额。预警功能的实现方案有很多种,我用的是最基础靠谱的一种:在库存表里维护一个预警阈值warn_threshold,启动时或者每次操作库存后,检查库存数量是否小于阈值,把预警的水果记录放到一张预警列表里。具体实现时,我在库存表结构里加了个warning_status字段,取值为0或1。当库存扣减后低于阈值,就在事务内把预警标志位置为1;当采购入库后数量回升到阈值以上,再改回0。这样做的好处是,前端页面只需要查询WHERE warning_status = 1就能展示所有需要补货的水果,不需要每次都去扫描全部库存。预警列表页通常还会附带“最近7天该水果的销售速度”这样的辅助信息,帮助老板决定补货量。这个信息可以从fruit_stock和fruit_info两张表加一个子查询算出来:用最近7天的销售总量除以7,得到日均销量,再对比当前库存,在页面上直接显示“预计还能卖3天”。这个功能虽然简单,但展示效果非常好,显得系统很“智能”。3.4 基于日期的销售报表统计报表统计是所有管理系统的门面,做不好会显得很没水平。我的报表模块实现思路是:用SQL原生函数对时间字段做格式化分组,统计销售额、订单量、客单价、销量、毛利这五个指标。销售额统计的核心SQL长这样:SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS day, COUNT(*) AS order_count, SUM(total_amount) AS total_sales, SUM(real_amount) / COUNT(*) AS avg_order_amount FROM sale_order WHERE create_time BETWEEN #{startTime} AND #{endTime} GROUP BY day ORDER BY day DESC;有人会问,为什么不直接在Java代码里把时间格式化,再用Map分组统计?我的回答是能交给数据库做的就不要留给应用层做。数据库的GROUP BY做了聚合运算,本身经过大量优化,比在Java层逐条分组快得多;而且SQL直观易读,看到语句就知道统计逻辑是什么,方便后期维护。毛利的计算稍微复杂一些,需要卖出水果的零售总额减去这批水果的采购成本。零售总额已经冗余存在sale_order_item表里了,采购成本要从采购单明细表中取。由于同一个水果在不同时间的采购价格可能不同,我采用的简化方案是:毛利 = 当前查询时段内售价总和 - 同时间段内该水果的加权平均采购单价乘以销售数量。在实际业务中,这个统计方式已经足够反映水果店的经营状况了。3.5 会员管理与积分累计水果店属于高频低客单价的生意,会员积分的作用在于增加顾客复购率。会员表的核心字段就是手机号、姓名、积分、等级。会员积分设计我参考了常见的快餐品牌:消费1元积1分,100积分可以抵扣1元。每次结账时,如果顾客报了手机号,店员就把会员信息带上,订单保存成功后,会员积分流水表里插入一条“增加积分”的记录,同时更新会员的总积分。积分抵扣要处理好一个问题:订单总金额、实收金额、优惠金额这三个数必须分开存。订单上打印的是总金额(比如100元),顾客积分抵扣了3元,实际收钱就是97元,订单表上三个字段分别记为100、97、3。如果不这样设计,日结对账的时候,店员手里的现金和系统里的报表永远对不上。4. 权限设计与部署上线4.1 基于角色的权限控制管理系统必然要处理“谁能看什么、谁能改什么”的问题。我的权限设计没有做成复杂的细粒度权限模型,而是采用“角色 + 接口拦截”的方案:管理员拥有全部权限,店员只能访问收银、商品查询、会员查询这些业务接口,不能进入报表统计、数据导出和系统管理页面。技术实现上,利用拦截器拦截所有/api/**请求,检查当前登录用户在数据库中的角色字段,然后判断是否有权限访问当前接口。代码结构如下:public class RoleInterceptor implements HandlerInterceptor { private static final Map<String, String> ROLE_REQUIREMENT = new HashMap<>(); static { ROLE_REQUIREMENT.put("/api/admin/**", "ADMIN"); ROLE_REQUIREMENT.put("/api/report/**", "ADMIN"); } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String uri = request.getRequestURI(); if (ROLE_REQUIREMENT.containsKey(uri)) { String role = ROLE_REQUIREMENT.get(uri); // 从session或token中获取当前用户角色 if (!hasRole(role)) { response.setStatus(403); return false; } } return true; } }如果项目需要更标准的安全认证,可以换成Spring Security + JWT的方案。JWT无状态认证的核心思路是:用户登录成功之后,服务器生成一段包含用户ID、角色、过期时间的签名Token返回给前端,前端后续请求都在Header里带上这个Token,后端通过校验签名来确定用户身份。这种方式对手机端和Web端都友好,不需要在服务端维护Session,是现在很多实战项目的主流做法。但要注意JWT有一个硬伤:Token一旦签发,在过期之前是无法主动撤销的。所以如果系统里存在“被封禁的账号”这种需求,要么加一层Token黑名单,要么退回去用Session方案。4.2 环境准备与一键部署环境准备这部分,如果有一台服务器,整个部署流程其实非常简单。我在实际部署时用的是生产环境Linux服务器加MySQL 8.0加JDK 8,项目打包成Jar包直接用nohup后台运行。部署前需要确认三个配置文件:server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/fruit_shop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 你的数据库密码 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 # 自定义的库存预警阈值,可以通过配置文件调整 fruit: stock: warn-threshold: 10为什么MySQL连接串里要加serverTimezone=Asia/Shanghai?这是我在第一次部署时踩过的坑。JDBC驱动和MySQL服务器时区不一致,会导致数据库里存的时间跟实际时间差了8个小时。存储时间不对,报表所有的时间维度统计就全乱了,排查起来非常隐蔽。前端打包后将静态页面放进项目的resources/static目录,再把整个项目打成Jar包,用下面这两条命令直接启动:mvn clean package -DskipTests nohup java -jar target/fruit-manager.jar --spring.profiles.active=prod > app.log 2>&1 &这种部署方案在毕设答辩时也让老师眼前一亮,毕竟很多同学还在用eclipse里跑Tomcat。但实际生产环境比这复杂一些,比如数据库密码一般不会明文暴露在配置文件里,而是通过环境变量注入。不过对毕设来说,这套流程已经非常够用了。5. 常见问题与排查技巧实录5.1 事务失效:一个被“自调用”坑掉的库存回滚我写系统的时候第一次遇到“事务没生效”这个问题,现象特别诡异:订单保存失败,但库存已经扣了。查代码发现,把保存订单和扣库存放在两个方法里,而这两个方法在同一个类中,调用时用的是this.xxx()这种方式。Spring事务是通过AOP代理实现的,this指向的是当前对象而不是代理对象,事务拦截器根本拦截不到,自然不会开启事务。解决办法要么把扣库存和保存订单抽到两个不同的Service类里,要么在同一个类中通过注入自身代理来调用。这是Spring框架最经典的“坑”之一,后来我每次写代码都留个心眼:事务方法绝不能在本类中直接调用。5.2 并发测试下的库存超卖问题系统写完以后,我用JMeter模拟了20个并发线程同时购买同一款苹果,结果是库存从100被扣成了负数。当时的代码就是最典型的“先查后扣”写法和不加条件的UPDATE语句。后来改成用条件更新的方式解决,跑完测试才恢复正常。这个测试给我留下的教训是:单机系统的并发问题不是不能出现,而是可以在开发测试阶段就通过并发工具暴露出来,别等到上线之后才发现。5.3 报表数据“慢”在缺少索引系统跑了一周,数据量接近几万条时,老板反馈报表页面加载特别慢。后来查看SQL执行计划,发现fruit_sale_order表的order_date字段没有建索引,GROUP BY导致MySQL做了一次全表扫描加临时表排序。解决办法很简单,在时间字段上加上普通索引:ALTER TABLE sale_order ADD INDEX idx_order_date (order_date); ALTER TABLE sale_order_item ADD INDEX idx_order_id (sale_order_id); ALTER TABLE fruit_stock ADD UNIQUE KEY uk_fruit_id (fruit_id);索引不是越多越好,但联表查询频繁用到的外键字段和时间范围查询字段,一定要加索引。这几行SQL让报表从3秒多降到了100毫秒以内,效果立竿见影。5.4 常见问题速查表问题现象根本原因解决方案事务内调用扣库存不生效同类内部方法调用绕过AOP代理拆成不同Service或注入自身代理并发下单库存变负数无并发控制,先查后改条件更新或乐观锁版本号时间统计差8小时JDBC与MySQL时区不一致连接串加serverTimezone=Asia/Shanghai报表越查越慢表数据量增大,无索引支持对group by及联表字段加索引数据库连接耗尽频繁长事务或连接池过小调整默认连接池配置与超时时间6. 代码结构实践心得6.1 分层结构怎么写才不low很多毕业设计代码看起来乱,根本原因是“万物皆Controller”:业务逻辑全都写在Controller里,一个方法写几百行,查询数据库、计算金额、拼装返回结果全堆在一起。这种代码不叫项目,叫大泥球。我采用的经典四层结构是:Controller(接收请求、参数校验)→ Service(业务逻辑、事务控制)→ Mapper/DAO(数据库操作)→ Entity(实体类)。另外画蛇添足地加了一层DTO,专门用来接收前端传过来的参数,而不是直接把实体类暴露出接口。实体类和DTO分开的好处在于:数据库表结构不想暴露给前端(比如sys_user表的密码字段),前端需要提交的数据也不一定和表字段一一对应。这样设计虽然多写几个类,但项目结构清晰,后期维护和扩展都会舒服很多。6.2 几个并不难但很加分的代码习惯第一,金额计算全部用BigDecimal,绝不用double或者float。float做金额计算会出现0.1 + 0.2不等于0.3这种问题,这是计算机浮点数存储的天然缺陷。用BigDecimal虽然写起来啰嗦一点,但金额精度是百分之百有保障的。第二,不要手动拼SQL字符串来查询数据库。字符串拼接SQL容易留下SQL注入漏洞,用过MyBatis就老老实实用#{}传参,它能自动帮你做参数转义,防注入。第三,统一响应体和异常处理。我定义了一个全局返回对象Result<T>,包含状态码、消息、数据三个字段,所有Controller都返回这个结构。再用@RestControllerAdvice全局捕获异常,这样前端的axios拦截器只用处理统一的响应结构,开发联调省事很多。第四,常量不要满天散。水果状态、订单状态这些字段的取值,要么用枚举类,要么用静态常量类统一管理。写死数字的代码最容易被后续维护的人骂街。写在最后的经验这套系统从前到后开发加上部署测试,满打满算用了一个多月。中间有无数个夜晚在为一个事务失效、一个时区错误翻来覆去地调试,但这些坑恰恰是毕设阶段最宝贵的收获。如果时间来得及,我的建议是做完基础功能之后,一定要给自己加一点“进阶亮点”:比如用定时任务实现库存日报自动生成,或者把前端页面换成更现代的UI组件库。这些花不了多少时间,但在系统完整性和答辩印象分上,会比“能用”高出一大截。做毕设不是为了答完辩就把代码扔掉。真正把它当成一个能跑的、有血有肉的业务系统来写,你会学到比框架本身多得多的东西。
毕业设计做水果管理系统,很多人第一步就选错了方向。当年我自己做这个题目的时候,网上能找到的资料不是太老就是太碎,照着折腾半个月,连库存扣减都整不明白。后来踩了一圈坑,重新梳理了需求和技术方案,才把整个系统从数据库到前端页面完整跑通。这篇就用我当时做完这个Spring Boot水果销售与库存管理系统的经历,把整个项目的设计思路、核心表结构、关键业务逻辑、权限控制和部署细节全部摊开来讲,代码能贴就贴,逻辑能拆就拆,给正在做类似选题的同学一份真正能“抄作业”的参考。这个系统要解决的问题很具体:一家水果店,需要管水果的进销存、会员和销售数据。放在技术术语里就是一套标准的单商户进销存系统,核心难点不在功能多,而在库存与销售之间的联动、数据一致性和报表统计。适合参考的人群也很明确:正在做Spring Boot管理类毕设的学生,以及刚入门想搞懂一个完整业务系统怎么落地的人。1. 项目整体设计与模块拆解1.1 为什么选Spring Boot而不是其他框架先说选型。现在做管理类毕设,Spring Boot几乎是最稳妥的选择,没有之一。它的核心价值在于“自动配置”和“起步依赖”这两个机制,让开发者把精力集中在业务逻辑上,而不是消耗在繁琐的XML配置和框架整合中。比如你需要操作数据库,引入一个spring-boot-starter-data-jpa或者mybatis-plus-boot-starter依赖,剩下的数据源、事务管理器、ORM映射框架基本都被自动配置好了。这和以前写SSM(Spring + Spring MVC + MyBatis)的体验完全不同。SSM时代,光spring-context.xml、spring-mvc.xml、mybatis-config.xml三个配置文件就能写上百行,配错一个扫描路径,项目启动直接报BeanCreationException,对刚接触框架的学生来说排查起来十分痛苦。Spring Boot把这些约定固化下来,“约定优于配置”,你用它的默认习惯写代码,几乎不需要手动声明Bean。在性能方面,很多人会担心Spring Boot太重、启动慢。这点在毕设和中小型业务场景下完全不是问题。内嵌的Tomcat让部署变成“打一个jar包丢服务器上就能跑”,没有独立Tomcat环境也能通过java -jar命令直接启动。对水果店这种日常请求量不大的场景,系统的瓶颈根本不在框架,而在并发下的数据一致性设计上。1.2 模块划分:从用户角色反推功能边界做业务系统先别急着写代码,先想清楚“谁会用到这个系统,他们分别要做什么”。我当时的系统就两类角色:管理员和店员。对管理员来说,日常关心的是三件事:水果库存够不够、哪些水果卖得好、一天的流水是多少。对应到功能就是库存管理、采购入库、销售出库、报表统计。对店员来说,核心任务就一个:结账开单。对应到功能就是收银、打印小票、登记会员。一辆车有四个轮子,这套系统也一样,无论页面怎么变,核心的六个功能模块是不会变的:商品管理:维护水果的基本信息,比如名称、产地、品类、进货价、零售价、单位、保质期。库存管理:实时展示各水果的剩余库存,支持盘点调整、库存预警。采购入库:登记供应商送货信息,录入采购数量和价格,自动增加库存。销售出库:收银开单,选择水果和数量后自动校验库存并扣减。会员管理:会员注册、积分累积、积分抵扣。数据报表:按天、按周、按月统计销售额、销量、毛利和滞销品。这六个模块不是孤立的,它们通过“库存”和“订单”这两条主线串起来。采购入库影响库存,销售出库也影响库存,而销售和采购的流水又共同产生报表数据。理解了这条数据流,整个系统的数据库设计也就清晰了。2. 数据库设计:从水果生意反推数据表2.1 核心表结构设计思路数据库设计是整个项目的灵魂。业务逻辑写错了可以改,表结构设计得不合理,后面每个功能写起来都别扭。我当时的建表思路是“拆表,不折腾”。所谓拆表,就是每个业务实体对应一张表,再通过外键和关联表把关系串起来。单商户进销存系统的核心表大概有这些:表名作用关键字段fruit_info水果基本信息id, name, category, origin, purchase_price, sale_price, unit, shelf_life, statusfruit_stock库存表id, fruit_id, quantity, warn_threshold, update_timesupplier供应商表id, name, contact_phone, addresspurchase_order采购单主表id, supplier_id, purchase_date, total_amount, statuspurchase_order_item采购单明细表id, purchase_order_id, fruit_id, quantity, price, amountsale_order销售订单主表id, order_no, member_id, order_date, total_amount, real_amount, discount_amountsale_order_item销售订单明细表id, sale_order_id, fruit_id, quantity, price, amountmember会员表id, phone, name, points, level, create_timesys_user系统用户表id, username, password, role, statuspoints_log积分流水表id, member_id, change_points, reason, create_time为什么要拆成“主表和明细表”两张表来记录一次采购或销售?原因很简单,一张订单里往往包含多种水果,如果所有数据都塞在同一行的逗号分隔字段里,统计的时候解析起来会非常痛苦。拆成主表和明细表后,订单ID关联明细表,可以精确定位“这次订单里有哪几种水果、每种多少斤、多少钱”,后续做“哪些水果卖得最好”这类统计,直接查明细表就行。2.2 库存表的版本号字段为什么必须有水果库存表fruit_stock在设计时,除了数量字段quantity和预警阈值warn_threshold,我还加了一个容易被忽略的字段:版本号version。这个字段是给乐观锁用的。什么叫乐观锁?简单说就是“我乐观地认为更新时没人来抢,所以不锁表,只在最后更新时检查一下版本号有没有变”。实际场景是这样的:两个店员同时收银,都选了一笔“红富士苹果卖出10斤”的操作,如果没有并发控制,两个事务同时读到库存100斤,各自扣10斤,最后写回时库存就只剩90斤,而实际应该剩下80斤。数据就这样悄悄错了。加了版本号之后,更新语句变成:UPDATE fruit_stock SET quantity = quantity - 10, version = version + 1 WHERE fruit_id = 1 AND version = 1;如果两个请求同时进来,第一个更新成功了,版本号从1变成2,第二个请求再去执行,发现version = 1的条件已经不满足,更新影响行数为0,就知道有人先改了库存,需要重新读取再重试。这就是用版本号解决并发扣减的经典做法。注意:库存表的主键ID加上水果ID建立唯一索引,防止同一水果出现多条库存记录,这也是我在实际开发中踩过的坑——一开始没加唯一约束,采购入库时不小心插入了两条库存记录,导致库存越看越乱。2.3 订单流水表与结算逻辑订单表我是和库存表分开设计的,当时觉得没必要,后来发现分开的好处非常大。销售订单表sale_order记录“哪笔订单、哪个会员、总金额多少”,明细表sale_order_item记录“这笔订单里具体买了什么”。一次结算的完整流程是这样的:收银员在前端勾选商品,点击结算,后端接收到订单和商品列表,先校验库存,再生成订单,最后扣减库存。订单流水可以非常清楚地记录每一笔交易,方便后续对账。这里有一个容易被忽视的设计细节:商品的价格不能直接取数据库中的实时价格,必须把成交时的价格冗余存到订单明细表里。不然水果价格调整后,历史订单里的总价会跟着变,统计出来的销售额就全错了。这个道理和“买完东西小票上写多少钱,这个价格不会因为超市第二天调价而改变”是一样的。3. 核心功能实现:从下单到扣库存的完整链路3.1 销售出库与库存扣减的原子性操作销售出库是整套系统里最核心、也最容易写错的功能。很多教程里的写法是这样的:先查库存,如果库存充足,再执行扣减SQL。这种“先查后改”的方式在并发高的时候会出问题——前面聊过的超卖。正确做法是把“检查库存”和“扣减库存”合并成一条SQL,让数据库在底层原子地完成这两个操作:@Override @Transactional(rollbackFor = Exception.class) public void createSaleOrder(SaleOrderDTO dto) { String orderNo = generateOrderNo(); SaleOrder order = new SaleOrder(); order.setOrderNo(orderNo); order.setMemberId(dto.getMemberId()); // 先计算订单总金额 BigDecimal totalAmount = BigDecimal.ZERO; for (SaleOrderItemDTO item : dto.getItems()) { FruitStock stock = fruitStockMapper.selectById(item.getFruitId()); if (stock == null) { throw new BusinessException("水果不存在"); } // 核心:条件更新,库存不足时影响行数为0 int rows = fruitStockMapper.deductStock(item.getFruitId(), item.getQuantity()); if (rows == 0) { throw new BusinessException("库存不足:" + item.getFruitName()); } // 计算明细金额 BigDecimal amount = item.getPrice().multiply(new BigDecimal(item.getQuantity())); // 组装明细对象 // ... } // 保存订单主表和明细表 }对应的Mapper SQL写成这样:<update id="deductStock"> UPDATE fruit_stock SET quantity = quantity - #{quantity} WHERE fruit_id = #{fruitId} AND quantity >= #{quantity} </update>这行SQL的意思是:只有当前库存大于等于要扣的数量才执行更新。影响的记录数是0,说明库存不够,直接抛异常,事务回滚,前面扣的其他水果也不会真正提交,整个订单取消。这里要特别注意事务的配置细节。@Transactional注解默认只对运行时异常(RuntimeException)回滚,但对于检查异常(Exception)是不会回滚的。为了保险,我在类上用了@Transactional(rollbackFor = Exception.class),这意味着无论是空指针异常还是业务自定义异常,只要抛出,整个事务就会回滚。这在生成订单这类多表写入操作里是必须的,不然可能出现“订单保存了,库存却扣了”这种两边不落好的情况。3.2 采购入库与库存联动采购入库和销售出库是一对“对称操作”:销售扣库存,采购加库存。业务逻辑上同样要处理“主表 + 明细表”的结构,然后循环把采购的水果数量累加到库存表里。采购入库还有一个库存初始化的场景。系统上线前,店铺里可能有几十斤苹果、几串香蕉已经摆在货架上了,这些库存还没录入系统,就需要通过“采购入库”的方式先录入,或者提供“期初库存录入”功能。我当时直接把期初库存也设计成一条特殊采购单,供应商字段显示“期初建账”,这样后续报表统计时,期初数据和其他采购混在一起,反而方便,不用额外开发一套逻辑。3.3 库存预警机制的实现细节库存预警是水果店老板最在意的功能之一。水果生鲜不同于工业品,库存积压会直接造成损耗,卖完了又没有及时补货,也影响营业额。预警功能的实现方案有很多种,我用的是最基础靠谱的一种:在库存表里维护一个预警阈值warn_threshold,启动时或者每次操作库存后,检查库存数量是否小于阈值,把预警的水果记录放到一张预警列表里。具体实现时,我在库存表结构里加了个warning_status字段,取值为0或1。当库存扣减后低于阈值,就在事务内把预警标志位置为1;当采购入库后数量回升到阈值以上,再改回0。这样做的好处是,前端页面只需要查询WHERE warning_status = 1就能展示所有需要补货的水果,不需要每次都去扫描全部库存。预警列表页通常还会附带“最近7天该水果的销售速度”这样的辅助信息,帮助老板决定补货量。这个信息可以从fruit_stock和fruit_info两张表加一个子查询算出来:用最近7天的销售总量除以7,得到日均销量,再对比当前库存,在页面上直接显示“预计还能卖3天”。这个功能虽然简单,但展示效果非常好,显得系统很“智能”。3.4 基于日期的销售报表统计报表统计是所有管理系统的门面,做不好会显得很没水平。我的报表模块实现思路是:用SQL原生函数对时间字段做格式化分组,统计销售额、订单量、客单价、销量、毛利这五个指标。销售额统计的核心SQL长这样:SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS day, COUNT(*) AS order_count, SUM(total_amount) AS total_sales, SUM(real_amount) / COUNT(*) AS avg_order_amount FROM sale_order WHERE create_time BETWEEN #{startTime} AND #{endTime} GROUP BY day ORDER BY day DESC;有人会问,为什么不直接在Java代码里把时间格式化,再用Map分组统计?我的回答是能交给数据库做的就不要留给应用层做。数据库的GROUP BY做了聚合运算,本身经过大量优化,比在Java层逐条分组快得多;而且SQL直观易读,看到语句就知道统计逻辑是什么,方便后期维护。毛利的计算稍微复杂一些,需要卖出水果的零售总额减去这批水果的采购成本。零售总额已经冗余存在sale_order_item表里了,采购成本要从采购单明细表中取。由于同一个水果在不同时间的采购价格可能不同,我采用的简化方案是:毛利 = 当前查询时段内售价总和 - 同时间段内该水果的加权平均采购单价乘以销售数量。在实际业务中,这个统计方式已经足够反映水果店的经营状况了。3.5 会员管理与积分累计水果店属于高频低客单价的生意,会员积分的作用在于增加顾客复购率。会员表的核心字段就是手机号、姓名、积分、等级。会员积分设计我参考了常见的快餐品牌:消费1元积1分,100积分可以抵扣1元。每次结账时,如果顾客报了手机号,店员就把会员信息带上,订单保存成功后,会员积分流水表里插入一条“增加积分”的记录,同时更新会员的总积分。积分抵扣要处理好一个问题:订单总金额、实收金额、优惠金额这三个数必须分开存。订单上打印的是总金额(比如100元),顾客积分抵扣了3元,实际收钱就是97元,订单表上三个字段分别记为100、97、3。如果不这样设计,日结对账的时候,店员手里的现金和系统里的报表永远对不上。4. 权限设计与部署上线4.1 基于角色的权限控制管理系统必然要处理“谁能看什么、谁能改什么”的问题。我的权限设计没有做成复杂的细粒度权限模型,而是采用“角色 + 接口拦截”的方案:管理员拥有全部权限,店员只能访问收银、商品查询、会员查询这些业务接口,不能进入报表统计、数据导出和系统管理页面。技术实现上,利用拦截器拦截所有/api/**请求,检查当前登录用户在数据库中的角色字段,然后判断是否有权限访问当前接口。代码结构如下:public class RoleInterceptor implements HandlerInterceptor { private static final Map<String, String> ROLE_REQUIREMENT = new HashMap<>(); static { ROLE_REQUIREMENT.put("/api/admin/**", "ADMIN"); ROLE_REQUIREMENT.put("/api/report/**", "ADMIN"); } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String uri = request.getRequestURI(); if (ROLE_REQUIREMENT.containsKey(uri)) { String role = ROLE_REQUIREMENT.get(uri); // 从session或token中获取当前用户角色 if (!hasRole(role)) { response.setStatus(403); return false; } } return true; } }如果项目需要更标准的安全认证,可以换成Spring Security + JWT的方案。JWT无状态认证的核心思路是:用户登录成功之后,服务器生成一段包含用户ID、角色、过期时间的签名Token返回给前端,前端后续请求都在Header里带上这个Token,后端通过校验签名来确定用户身份。这种方式对手机端和Web端都友好,不需要在服务端维护Session,是现在很多实战项目的主流做法。但要注意JWT有一个硬伤:Token一旦签发,在过期之前是无法主动撤销的。所以如果系统里存在“被封禁的账号”这种需求,要么加一层Token黑名单,要么退回去用Session方案。4.2 环境准备与一键部署环境准备这部分,如果有一台服务器,整个部署流程其实非常简单。我在实际部署时用的是生产环境Linux服务器加MySQL 8.0加JDK 8,项目打包成Jar包直接用nohup后台运行。部署前需要确认三个配置文件:server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/fruit_shop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 你的数据库密码 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 # 自定义的库存预警阈值,可以通过配置文件调整 fruit: stock: warn-threshold: 10为什么MySQL连接串里要加serverTimezone=Asia/Shanghai?这是我在第一次部署时踩过的坑。JDBC驱动和MySQL服务器时区不一致,会导致数据库里存的时间跟实际时间差了8个小时。存储时间不对,报表所有的时间维度统计就全乱了,排查起来非常隐蔽。前端打包后将静态页面放进项目的resources/static目录,再把整个项目打成Jar包,用下面这两条命令直接启动:mvn clean package -DskipTests nohup java -jar target/fruit-manager.jar --spring.profiles.active=prod > app.log 2>&1 &这种部署方案在毕设答辩时也让老师眼前一亮,毕竟很多同学还在用eclipse里跑Tomcat。但实际生产环境比这复杂一些,比如数据库密码一般不会明文暴露在配置文件里,而是通过环境变量注入。不过对毕设来说,这套流程已经非常够用了。5. 常见问题与排查技巧实录5.1 事务失效:一个被“自调用”坑掉的库存回滚我写系统的时候第一次遇到“事务没生效”这个问题,现象特别诡异:订单保存失败,但库存已经扣了。查代码发现,把保存订单和扣库存放在两个方法里,而这两个方法在同一个类中,调用时用的是this.xxx()这种方式。Spring事务是通过AOP代理实现的,this指向的是当前对象而不是代理对象,事务拦截器根本拦截不到,自然不会开启事务。解决办法要么把扣库存和保存订单抽到两个不同的Service类里,要么在同一个类中通过注入自身代理来调用。这是Spring框架最经典的“坑”之一,后来我每次写代码都留个心眼:事务方法绝不能在本类中直接调用。5.2 并发测试下的库存超卖问题系统写完以后,我用JMeter模拟了20个并发线程同时购买同一款苹果,结果是库存从100被扣成了负数。当时的代码就是最典型的“先查后扣”写法和不加条件的UPDATE语句。后来改成用条件更新的方式解决,跑完测试才恢复正常。这个测试给我留下的教训是:单机系统的并发问题不是不能出现,而是可以在开发测试阶段就通过并发工具暴露出来,别等到上线之后才发现。5.3 报表数据“慢”在缺少索引系统跑了一周,数据量接近几万条时,老板反馈报表页面加载特别慢。后来查看SQL执行计划,发现fruit_sale_order表的order_date字段没有建索引,GROUP BY导致MySQL做了一次全表扫描加临时表排序。解决办法很简单,在时间字段上加上普通索引:ALTER TABLE sale_order ADD INDEX idx_order_date (order_date); ALTER TABLE sale_order_item ADD INDEX idx_order_id (sale_order_id); ALTER TABLE fruit_stock ADD UNIQUE KEY uk_fruit_id (fruit_id);索引不是越多越好,但联表查询频繁用到的外键字段和时间范围查询字段,一定要加索引。这几行SQL让报表从3秒多降到了100毫秒以内,效果立竿见影。5.4 常见问题速查表问题现象根本原因解决方案事务内调用扣库存不生效同类内部方法调用绕过AOP代理拆成不同Service或注入自身代理并发下单库存变负数无并发控制,先查后改条件更新或乐观锁版本号时间统计差8小时JDBC与MySQL时区不一致连接串加serverTimezone=Asia/Shanghai报表越查越慢表数据量增大,无索引支持对group by及联表字段加索引数据库连接耗尽频繁长事务或连接池过小调整默认连接池配置与超时时间6. 代码结构实践心得6.1 分层结构怎么写才不low很多毕业设计代码看起来乱,根本原因是“万物皆Controller”:业务逻辑全都写在Controller里,一个方法写几百行,查询数据库、计算金额、拼装返回结果全堆在一起。这种代码不叫项目,叫大泥球。我采用的经典四层结构是:Controller(接收请求、参数校验)→ Service(业务逻辑、事务控制)→ Mapper/DAO(数据库操作)→ Entity(实体类)。另外画蛇添足地加了一层DTO,专门用来接收前端传过来的参数,而不是直接把实体类暴露出接口。实体类和DTO分开的好处在于:数据库表结构不想暴露给前端(比如sys_user表的密码字段),前端需要提交的数据也不一定和表字段一一对应。这样设计虽然多写几个类,但项目结构清晰,后期维护和扩展都会舒服很多。6.2 几个并不难但很加分的代码习惯第一,金额计算全部用BigDecimal,绝不用double或者float。float做金额计算会出现0.1 + 0.2不等于0.3这种问题,这是计算机浮点数存储的天然缺陷。用BigDecimal虽然写起来啰嗦一点,但金额精度是百分之百有保障的。第二,不要手动拼SQL字符串来查询数据库。字符串拼接SQL容易留下SQL注入漏洞,用过MyBatis就老老实实用#{}传参,它能自动帮你做参数转义,防注入。第三,统一响应体和异常处理。我定义了一个全局返回对象Result<T>,包含状态码、消息、数据三个字段,所有Controller都返回这个结构。再用@RestControllerAdvice全局捕获异常,这样前端的axios拦截器只用处理统一的响应结构,开发联调省事很多。第四,常量不要满天散。水果状态、订单状态这些字段的取值,要么用枚举类,要么用静态常量类统一管理。写死数字的代码最容易被后续维护的人骂街。写在最后的经验这套系统从前到后开发加上部署测试,满打满算用了一个多月。中间有无数个夜晚在为一个事务失效、一个时区错误翻来覆去地调试,但这些坑恰恰是毕设阶段最宝贵的收获。如果时间来得及,我的建议是做完基础功能之后,一定要给自己加一点“进阶亮点”:比如用定时任务实现库存日报自动生成,或者把前端页面换成更现代的UI组件库。这些花不了多少时间,但在系统完整性和答辩印象分上,会比“能用”高出一大截。做毕设不是为了答完辩就把代码扔掉。真正把它当成一个能跑的、有血有肉的业务系统来写,你会学到比框架本身多得多的东西。