简介:本资源为基于Java语言的柑橘类水果管理系统设计源码,面向计算机相关专业学生、Java初学者及需要课程设计或毕业设计参考的开发者,帮助解决水果生产、销售与库存跟踪等业务场景下的系统搭建问题。压缩包共554个文件,约39.79MB,其中179个XML配置文件负责数据库连接、服务器及运行参数设置,120个Java源文件承载水果信息录入、库存管理、销售记录等核心业务逻辑,另有2个SQL脚本用于建表与初始化数据,以及iml项目配置、properties、json、cookies、http等辅助文件,便于在IDEA中导入运行与调试。资源还包含readme.txt入门说明与doc文档,可帮助读者快速理解系统结构与运行机制。目前已有286人学习下载,适合作为课程设计、毕业设计或Java Web入门练手项目,通过阅读源码掌握分层设计、配置管理与数据库持久化的完整实现思路。
1. 柑橘类水果管理系统到底管什么:从果园台账到出库结算的一条链
柑橘类水果管理系统,说白了就是把果园、批次、库存、订单、结算这几件事塞进一套 Java 后端里跑起来。我最早接触这类需求是在一个柑橘合作社,他们当时用 Excel 记采摘批次,结果同一批果子在冷库和档口之间来回调拨,账面上多出三百斤,谁也说不清去哪了。这类系统的核心不是“管理水果”,而是管理批次与库存的流转关系——一棵树上的果子从采摘那刻起就有批次号,之后每一次分拣、入库、调拨、出库都要留痕。
这套系统适合谁?做 Java 课程设计的学生、接农业信息化小单的外包团队、以及想给自己果园或合作社搭一套内部台账的开发者。它不复杂,但涉及的知识点很全:Spring Boot 分层、MyBatis-Plus 的 CRUD 与条件构造、库存扣减的并发控制、以及最容易被忽略的批次追溯。热搜里常出现“java课程设计案例源码”“spring boot + mybatis 开源商城源码”,其实柑橘管理系统就是这类项目的农业垂直版本,把商品换成有保质期、有产地、有等级的水果,业务约束反而更清晰。下面按我实际搭过的一版结构,把选型、建表、核心接口和踩坑一次讲透。
2. 技术选型与工程骨架:为什么用 Spring Boot + MyBatis-Plus 而不是裸 Servlet
2.1 分层结构与依赖取舍
柑橘管理系统的业务量不大,但实体关系不简单:产地、品种、等级、批次、仓库、客户、订单,七张核心表互相外键关联。用裸 Servlet + JDBC 写,光是 ResultSet 到对象的映射就能写到你怀疑人生。我一般直接上 Spring Boot 2.7 + MyBatis-Plus,理由有三条:一是 MyBatis-Plus 的BaseMapper省掉 80% 的单表 CRUD;二是它的条件构造器LambdaQueryWrapper在写“查某产地某等级且库存大于零的批次”这种组合条件时,比手写 XML 清爽得多;三是 Spring Boot 的@Transactional能直接管住库存扣减和订单写入的一致性。
依赖清单我固定用这几个,版本按你本地 Maven 仓库能拉到的稳定版走:
<!-- pom.xml 关键依赖 --> <dependencies> <!-- Web 层,提供 REST 接口 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis-Plus,注意用 boot-starter 而非裸 mybatis --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- Lombok,省掉 getter/setter --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>这段依赖里最容易被新手忽略的是 MyBatis-Plus 的 starter 和裸mybatis的区别:用错 starter 会导致BaseMapper注入失败,启动直接报NoSuchBeanDefinitionException。另外 MySQL 驱动从 8.x 起包名是com.mysql.cj.jdbc.Driver,配置文件里别写成老的com.mysql.jdbc.Driver,否则连接池初始化就抛异常。
2.2 包结构与配置项
包结构我按controller / service / mapper / entity / dto / config六层分,别把 entity 和 dto 混用——柑橘系统里“批次”在入库时只需要产地和数量,出库时却要带客户和结算价,字段差异大,混用会让接口参数越来越臃肿。application.yml里几个必调参数:
spring: datasource: url: jdbc:mysql://localhost:3306/citrus_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true # 数据库 batch_no 自动映射 batchNo log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发期打印 SQL global-config: db-config: id-type: auto # 主键自增,别用默认的雪花 ID 除非分库 logic-delete-field: deleted # 逻辑删除字段map-underscore-to-camel-case必须开,否则batch_no映射不到batchNo,查出来全是 null,这个坑我见过至少五个人踩。log-impl只在开发期开,上线关掉,不然日志量能把磁盘写满。逻辑删除字段建议加上,柑橘批次被“删除”时实际是标记deleted=1,否则历史订单关联的批次会变成孤儿记录,追溯直接断链。
3. 核心表结构与批次追溯:七张表怎么建才不返工
3.1 建表 SQL 与字段含义
表设计是这类系统返工率最高的地方。我第一版把产地、品种、等级全塞进一张fruit表,结果同一品种不同产地没法区分价格,只能推倒重来。正确做法是拆成基础维度表和业务流转表。核心七张表如下:
| 表名 | 作用 | 关键字段 |
|---|---|---|
origin | 产地 | id, name, region |
variety | 品种 | id, name, season |
grade | 等级 | id, name, price_factor |
batch | 采摘批次 | id, batch_no, origin_id, variety_id, grade_id, quantity, produce_date |
warehouse | 仓库 | id, name, capacity |
stock | 库存流水 | id, batch_id, warehouse_id, change_qty, type, create_time |
order | 订单 | id, customer, batch_id, qty, amount, status |
建表时batch_no加唯一索引,格式用产地码+品种码+日期+序号,比如GX-WG-20240315-001。stock表只记增量不记存量,存量由流水累加得出,这样任何一次库存对不上都能顺着流水查到具体哪一笔。下面是batch和stock的建表语句:
CREATE TABLE batch ( id BIGINT PRIMARY KEY AUTO_INCREMENT, batch_no VARCHAR(64) NOT NULL UNIQUE COMMENT '批次号,全局唯一', origin_id BIGINT NOT NULL, variety_id BIGINT NOT NULL, grade_id BIGINT NOT NULL, quantity DECIMAL(10,2) NOT NULL COMMENT '采摘数量,单位斤', produce_date DATE NOT NULL, deleted TINYINT DEFAULT 0, INDEX idx_produce (produce_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, batch_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, change_qty DECIMAL(10,2) NOT NULL COMMENT '正数入库,负数出库', type VARCHAR(16) NOT NULL COMMENT 'IN/OUT/TRANSFER', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_batch (batch_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;quantity用DECIMAL不用INT,因为柑橘按斤称,半斤八两是常态,用整数会丢精度。stock表的change_qty允许负数,出库记负、入库记正,累加即当前库存,这个设计比单独维护一张存量表更不容易出错——存量表一旦更新失败就和流水对不上,而流水是只增不改的。
3.2 用 MyBatis-Plus 生成实体与 Mapper
实体类用 Lombok 的@Data,字段名和表字段驼峰对应。Batch实体:
@Data @TableName("batch") public class Batch { @TableId(type = IdType.AUTO) private Long id; private String batchNo; private Long originId; private Long varietyId; private Long gradeId; private BigDecimal quantity; private LocalDate produceDate; @TableLogic private Integer deleted; }@TableLogic注解配合配置里的logic-delete-field生效,调用removeById时自动改成UPDATE batch SET deleted=1,查询自动过滤deleted=0。Mapper 接口只需继承BaseMapper<Batch>,不用写一行 XML 就有增删改查。这里有个细节:@TableId(type = IdType.AUTO)必须和数据库自增一致,如果数据库是自增而实体写成ASSIGN_ID,插入时 MyBatis-Plus 会自己生成雪花 ID 覆盖,导致自增列失效,这个坑排查起来很费时间,因为 SQL 日志看起来完全正常。
4. 库存扣减与订单接口:并发下怎么保证不超卖
4.1 库存扣减的两种写法与选择
柑橘出库时最怕超卖——仓库里只有 100 斤,两个订单同时扣,结果都扣成功,账面变成 -50 斤。常见做法有两种:一是UPDATE stock SET ... WHERE quantity >= ?的乐观锁写法,二是SELECT ... FOR UPDATE的行锁写法。我一般用第一种,因为柑橘系统的并发量不高,乐观锁足够,且不用开事务锁等待。
具体到代码,扣减前先查当前库存,再插入一条负数流水,同时校验结果:
@Service public class StockService { @Autowired private StockMapper stockMapper; /** * 出库扣减,返回是否成功 * @param batchId 批次ID * @param qty 出库数量,正数 */ @Transactional(rollbackFor = Exception.class) public boolean outbound(Long batchId, BigDecimal qty) { // 累加流水得到当前库存 BigDecimal current = stockMapper.sumByBatchId(batchId); if (current == null || current.compareTo(qty) < 0) { return false; // 库存不足,直接拒绝 } Stock stock = new Stock(); stock.setBatchId(batchId); stock.setChangeQty(qty.negate()); // 出库记负数 stock.setType("OUT"); stockMapper.insert(stock); return true; } }sumByBatchId是一条自定义 SQL:SELECT COALESCE(SUM(change_qty),0) FROM stock WHERE batch_id = ?。用COALESCE是因为没有流水时SUM返回 null,直接比较会抛 NPE。@Transactional保证查库存和插流水在一个事务里,但注意:这个写法在高并发下仍有窗口——两个线程同时查到 100,都判断通过。要彻底解决,得在sumByBatchId上加FOR UPDATE,或者用数据库层面的唯一约束兜底。柑橘场景并发低,我通常加一个应用层锁(按 batchId 加ReentrantLock)就够了,别过度设计。
4.2 订单接口的参数校验与状态机
订单接口接收batchId、customer、qty,返回订单号和结算金额。金额 = 数量 × 等级系数 × 基础单价。参数校验用@Valid+ JSR303 注解,别在 service 里手写一堆 if:
@PostMapping("/order/create") public Result<OrderVO> create(@RequestBody @Valid OrderDTO dto) { // dto 里 qty 用 @DecimalMin("0.01") 校验 return Result.ok(orderService.create(dto)); }OrderDTO的qty字段加@DecimalMin(value = "0.01", message = "出库数量必须大于零"),batchId加@NotNull。订单状态用枚举PENDING / PAID / SHIPPED / DONE,状态流转只能单向,别允许从DONE改回PENDING,否则对账时会出现已结算订单又被修改的情况。状态机我一般用一张order_status_log表记录每次变更,出问题时能查到是谁在什么时候改的。
5. 避坑与排查:柑橘系统上线后最常翻车的五个点
5.1 批次号重复导致插入失败
现象:采摘旺季批量导入批次,日志报Duplicate entry 'GX-WG-20240315-001' for key 'batch_no'。原因是批次号生成用了“日期+序号”,但序号是查当天最大序号再加一,两个请求同时查到相同最大值。解决:把序号生成放到数据库唯一索引兜底,捕获DuplicateKeyException后重试一次,或者直接用batch_no加时间戳毫秒。我后来改成日期+仓库码+自增序列,序列由数据库AUTO_INCREMENT提供,彻底避开并发。
5.2 逻辑删除后关联查询查不到数据
现象:删掉一个批次后,历史订单详情页的批次信息变成空白。原因是@TableLogic让批次查询自动过滤deleted=1,但订单关联查询走的是 join,join 条件里没带deleted=0,或者带了却把已删批次过滤掉了。解决:历史订单展示时用单独的查询,明确include deleted,或者干脆不物理删批次,只标记状态为“停用”。我的习惯是业务主表不做逻辑删除,用状态字段控制,逻辑删除只用在草稿类数据上。
5.3 库存流水累加出现小数精度误差
现象:100 斤果子分三次出库,33.33 + 33.33 + 33.33,账面剩 0.01 斤,对不上。原因是BigDecimal除法没指定精度,或者用了double做中间计算。解决:所有数量字段用BigDecimal,除法必须带setScale(2, RoundingMode.HALF_UP),比较用compareTo不用equals。equals会比较 scale,33.30和33.3用equals返回 false,这个坑我在对账时踩过,查了一下午。
5.4 事务失效导致库存扣了订单没生成
现象:出库成功但订单表没记录,或者反过来。原因是@Transactional加在 private 方法上,或者同类内部方法调用绕过了代理。解决:事务方法必须是 public,且从外部类调用。如果确实要在同类内调用,注入自身代理或用AopContext.currentProxy()。另外注意rollbackFor = Exception.class要显式写,默认只回滚RuntimeException,受检异常不回滚。
5.5 时间字段时区错乱
现象:采摘日期显示比实际早一天。原因是 MySQL 连接串没配serverTimezone,或者实体用java.util.Date而数据库是DATE。解决:连接串加serverTimezone=Asia/Shanghai,实体日期字段统一用LocalDate/LocalDateTime,别用Date。LocalDate和 MySQL 的DATE类型映射干净,没有时区偏移问题。
6. 进阶技巧:用 MyBatis-Plus 条件构造器做多维度库存看板
系统跑起来后,合作社最想要的是一个看板:按产地、品种、等级、仓库四个维度看当前库存。手写四条 SQL 再在 Java 里拼装太笨,MyBatis-Plus 的LambdaQueryWrapper配合groupBy能一次搞定。下面这段是我实际用的库存汇总查询:
public List<StockView> dashboard(String origin, String variety, String grade) { LambdaQueryWrapper<Stock> wrapper = new LambdaQueryWrapper<>(); // 动态条件:传了才拼,不传查全部 wrapper.eq(StringUtils.hasText(origin), Stock::getOriginName, origin) .eq(StringUtils.hasText(variety), Stock::getVarietyName, variety) .eq(StringUtils.hasText(grade), Stock::getGradeName, grade) .groupBy(Stock::getOriginName, Stock::getVarietyName, Stock::getGradeName) .select(Stock::getOriginName, Stock::getVarietyName, Stock::getGradeName, "SUM(change_qty) as totalQty"); return stockMapper.selectList(wrapper); }eq的第一个参数是布尔条件,为 false 时整个条件不拼进 SQL,这就是动态查询的关键,比在 XML 里写一堆<if>标签清爽。groupBy后select里除了分组字段,其余必须用聚合函数,否则 MySQL 的ONLY_FULL_GROUP_BY模式会直接报错。SUM(change_qty)得到的是净库存,正负流水自动抵消。
看板接口建议加缓存,因为库存汇总查询会扫全表流水,数据量上万后响应变慢。我一般用 Spring Cache + Redis,缓存 key 按查询维度拼,过期时间设 5 分钟。注意缓存和事务的配合:出库成功后要主动清缓存,别等过期,否则看板数据滞后,合作社的人会以为系统坏了。
最后说个验证方法:拿一批真实数据,手动在数据库里插几条流水,然后调看板接口,核对SUM结果和手算是否一致。我习惯在测试环境留一个batch_id = -1的测试批次,专门用来跑边界用例,比如零库存出库、负数入库、超大数量。这个习惯帮我提前发现过三次精度问题。做这类系统,账目对得上比功能多更重要,批次追溯链一旦断了,后面全是血泪经验。希望帮到你。
本文还有配套的精品资源,点击获取