做实验室设备管理这块,我踩过的坑不算少。设备借出去了不知道在谁手里、归还时间到了没人提醒、设备坏了报修全靠一张纸质单子来回传,最后对账的时候全靠翻聊天记录。这套基于Spring Boot的实验室设备租赁报修管理系统,就是为了把这两条核心业务链——租赁和报修,全部线上化、流程化、可追溯。整体跑下来,租赁流程从申请到归还全程留痕,报修流程从提交到验收状态透明,几千行的业务代码里也确实藏了不少值得展开讲的细节。
这篇文章我打算从整体设计、数据建模、核心流程实现、权限对接、文件处理、部署排障这几个维度,把该系统从零搭建过程中的关键决策、参数取舍、踩坑记录完整摊开。无论是准备做类似管理系统的同学,还是正在用Spring Boot做业务系统但被流程设计、事务、文件上传这些事卡住的人,都可以直接参考这里的方案。我会尽量把每个选择背后的原因讲清楚,而不是只丢一堆代码。
1. 项目整体设计与技术选型思路
1.1 为什么选Spring Boot而不是其他框架
实验室设备管理系统本质上是典型的CRUD加业务流程应用,核心价值在于把租赁和报修这两条业务线理清楚,而不是追求极致的并发性能或复杂分布式能力。因此我选型时最看重的三个点是:开发效率、生态成熟度、团队上手难度。
Spring Boot在这三点上几乎没有短板。它内置了Tomcat容器,省略了传统SSH项目里繁琐的XML配置;Bean的管理交给容器,业务代码只要关注Service和Controller层的逻辑。相比早期Spring MVC项目,省掉了大量web.xml、spring-mvc.xml、数据源配置等模板化文件。对高校实验室、企业内部设备管理这类中小型系统,Spring Boot的启动速度和内存占用也都足够。
这里有一个关键决策:Spring Boot版本选型不能只看“新”。这套系统的部署环境是JDK 1.8,因此直接锁定Spring Boot 2.7.x系列,而不是Spring Boot 3.x。原因很直接:Spring Boot 3.0开始强制要求JDK 17,并且包名从javax迁移到了jakarta。如果强行用新版本,老系统里所有import javax.*的代码全部要改,涉及面太大。网上经常看到“Spring Boot版本太高”导致项目启动报错或者依赖冲突的帖子,绝大多数就是因为没先确认JDK版本。
注意:确定Spring Boot版本前,先确认三件事:JDK版本、已有依赖的兼容版本、是否需要用到特定中间件的新版客户端。版本这东西,匹配比追新重要。
1.2 整体技术栈与分层结构
这套系统的技术栈组成如下表所示,都是Spring Boot生态里非常成熟的组件:
| 技术组件 | 选型版本 | 用途说明 |
|---|---|---|
| Spring Boot | 2.7.18 | 基础框架,整合所有组件 |
| MyBatis Plus | 3.5.x | 数据持久层,内置CRUD方法,减少重复SQL |
| MySQL | 8.0 | 业务数据存储 |
| Redis | 6.x | 缓存、验证码、分布式锁场景 |
| Spring Security + JWT | 2.7.x / jjwt 0.9.1 | 登录认证与接口鉴权 |
| Minio | 8.x | 设备图片、维修附件、租赁合同的文件存储 |
| Spring Task / Quartz | Spring Boot默认 / 可选 | 定时任务:超期提醒、租赁到期检查 |
| Swagger | springfox / knife4j | 接口文档生成,方便前后端联调 |
| Docker + Docker Desktop | 20.x | 项目容器化部署 |
系统分层采用经典的前后端分离架构,前端Vue + Element UI,后端只提供RESTful API。服务端分五层:
- Controller层:接收参数、校验入参、调用Service、返回统一结果封装。
- Service层:业务逻辑处理,事务控制放在这一层。
- Mapper层:继承MyBatis Plus的BaseMapper,复杂查询用注解SQL或XML。
- Entity层:数据库表映射实体,字段与表结构一一对应。
- DTO/VO层:接口入参和出参对象,避免直接把Entity暴露给前端。
这里要强调一个容易忽略的细节:Entity不要直接作为接口的返回对象。因为数据库表字段往往比前端需要的信息多,直接把Entity序列化出去要么泄露内部字段,要么返回大量无用数据。实际开发中我习惯为每个模块单独建VO,哪怕多写几个字段映射,后面接口变动时会省很多事。
1.3 业务边界划分:租赁与报修是两条独立闭环
梳理需求时最重要的一件事,是把“租赁”和“报修”拆成两条独立的业务闭环,而不是混在一个模块里。从表面上看,设备被借走后损坏了才走报修流程,两者存在关联,但它们的生命周期、状态流转、参与角色完全不同。
租赁闭环是:预约申请→管理员审批→领取设备→设备使用→到期归还→费用结算。参与角色至少有学生/教师(借用人)、实验室管理员(审批人)、财务(费用核算,视具体需求)。
报修闭环是:提交报修→管理员派单→维修人员接单处理→维修完成→验收归档。参与角色是报修人、管理员、维修师傅。
如果把两条闭环混在一个Service里管理,代码会越来越难维护,一旦流程变化(比如租赁需要支持续租、报修需要支持转单),改动会波及大量无关代码。实际项目中,com.example.lab.modules.lease和com.example.lab.modules.repair两个包完全独立,只在需要关联查询时通过设备ID做跨模块调用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库建模与核心业务表设计
2.1 设备信息表:一切业务的起点
设备表是整个系统的地基,租赁和报修都围绕设备ID展开。这张表的设计决定了后续所有业务的扩展空间。实际设计时我用了如下核心字段:
- id:主键,用MyBatis Plus的雪花算法生成,不使用数据库自增。原因很简单:系统后续很可能做分库分表或者数据迁移,自增ID在分布式场景下有冲突风险;雪花ID在插入前就能拿到,方便在业务层组装父子表关系。
- device_code:设备编号,业务上唯一,格式如LAB-2024-0001,用于线下扫码或手动录入时定位设备。
- device_name:设备名称。
- category_id:设备分类ID,关联设备分类表。
- status:设备当前状态。这里的状态值需要谨慎设计,我用了整数枚举,取值和含义如下表。
- location:存放位置(实验室房间号)。
- purchase_date、warranty_expire:采购日期和保修截止时间,报修时用于判断是否在保。
- image_url:设备图片在Minio中的路径。
- remark:备注说明。
设备状态设计是整个系统里容易出错的地方。租赁流程会改变设备状态,报修流程也会改变设备状态,如果我直接用两个模块各自更新status字段,很容易出现状态覆盖。我的处理方式是将设备状态收敛为五个值:
| 状态值 | 含义 | 说明 |
|---|---|---|
| 0 | 空闲 | 可被租赁 |
| 1 | 已借出 | 处于租赁周期内 |
| 2 | 维修中 | 设备故障,禁止租赁和领取 |
| 3 | 报废 | 不可用状态 |
| 4 | 待领取 | 租赁审批通过,等借用人线下领取 |
这个状态机的好处是,设备“生命周期”一个字段就能表达完整。新建设备默认0;租赁审批通过后置为4;借用人确认领取后置为1;报修工单创建后置为2;维修完成验收通过后,根据是否有未完结租赁单决定恢复为0或1。
注意:设备状态严禁在报修和租赁的Service里直接setStatus。所有状态变更必须通过统一的状态流转方法处理,并在方法内校验当前状态与目标状态是否允许跳转。我见过最典型的bug就是:设备在维修中,结果租赁模块一审批,直接把状态从2改成了4,设备还没修好就被借出去了。
2.2 租赁订单表:状态机与费用逻辑
租赁订单表(lease_order)是租赁闭环的核心。关键字段包括:
- order_no:租赁单号,格式如LO20240815001,方便线下沟通和查询。
- user_id:借用人ID。
- equipment_ids:这里如果只租一台设备,可以直接存device_id;但如果允许一次申请多台,建议拆出子表lease_order_item。我在设计时直接拆了子表,一台设备一行记录,归还时逐台登记。
- borrow_date、expected_return_date:实际借用日期和预计归还日期。
- actual_return_date:实际归还日期,为Null时表示未归还。
- total_amount:租金总金额。
- status:租赁单状态,取值如下表。
租赁单状态机是审批流和线下行为的组合,我按实际业务推进顺序设计了七个状态:
| 状态值 | 含义 | 触发操作 |
|---|---|---|
| 0 | 待审批 | 用户提交申请后 |
| 1 | 审批通过(待领取) | 管理员审批通过 |
| 2 | 已领取(租赁中) | 借用人线下领取设备 |
| 3 | 已归还(待结算) | 借用人归还设备 |
| 4 | 已完成 | 管理员确认费用结算后 |
| 5 | 已驳回 | 管理员审批驳回 |
| 6 | 已取消 | 用户自行取消或超时未领取自动取消 |
这里有一个值得展开的设计细节:归还和结算拆开。实际业务中,设备管理员检查设备完好后先登记归还,费用计算涉及租期天数、超期费用、设备损坏赔偿等,需要时间核算,所以把“归还”和“结算完成”拆成两个状态。如果不拆,API里一个“归还”按钮就要做太多事,而且一旦算错费用很难回退。
租金计算规则我放在常量配置类里维护:
- 基础租金 = 设备每日租金单价 × 租赁天数。
- 超期租金:超出预计归还日期后,每日按基础单价的1.5倍计费。
- 损坏赔偿:根据报修单中的维修费用,由管理员在结算时手动录入,参与最终结算金额。
费用计算的关键在于“租赁天数”怎么算。我用的是自然日对齐,归还当天不计费。即:租赁天数 = (actual_return_date - borrow_date)的Calendar.DAY_OF_YEAR差值。如果预计8月1日借出、8月10日归还,按8月10日减8月1日等于9天计算,而不是算10天。这个约定需要在系统里明确,否则前端展示、后端计算、人工核对三方容易对不上。
2.3 报修工单表:维修全流程留痕
报修工单表(repair_order)是报修闭环的核心。字段设计如下:
- repair_no:工单号,格式如RO20240815001。
- device_id:报修设备ID。
- reporter_id:报修人ID。
- fault_desc:故障描述,用户填写,尽量用文本而非下拉框,因为故障原因千奇百怪。
- fault_type:故障类型,可选项如“硬件故障”“软件故障”“耗材更换”“外观损坏”。
- priority:紧急程度,普通/紧急。紧急工单在后台列表有醒目标识,派单时有单独筛选。
- attachment_urls:故障照片或视频的Minio路径,多个用逗号分隔。
- assignee_id:维修人员ID,管理员派单时指定。
- repair_result:维修结果描述。
- cost_amount:维修费用,用于登记耗材、人工成本。
- status:工单状态。关键状态如下表。
| 状态值 | 含义 | 触发操作 |
|---|---|---|
| 0 | 待派单 | 用户提交报修 |
| 1 | 维修中 | 维修人员接单 |
| 2 | 已修复待验收 | 维修人员提交维修结果,等待管理员或报修人验收 |
| 3 | 已验收 | 完成闭环 |
| 4 | 已驳回 | 管理员驳回工单,需重新补充信息 |
| 5 | 已关闭 | 特殊情况人工关闭 |
这里有个很实际的细节:报修单在维修人员提交“待验收”之后,设备状态不能立即从“维修中”恢复为空闲。设备要经过验收确认确实修好了,才能解除占用。如果没有这层状态,维修工只管填个完成,设备状态自动恢复可租,结果实际没修好,下一单借出去又是问题。
为了保证流程变更可追溯,我加了repair_process记录表,即操作日志表。每一次状态变更都插入一条记录,包括操作人、操作时间、变更前状态、变更后状态、操作说明。这表有两个实际用途:一是出现纠纷时追溯到人、到点、到动作;二是后台可以展示完整流转时间线,用户体验非常好。注意,不能用update_time字段代替操作日志,因为update_time只记录最后一次变更,中间过程会丢失。
2.4 用户与权限模型设计
用户体系沿用经典的RBAC模型,五张表:sys_user、sys_role、sys_menu、sys_user_role、sys_role_menu。用户与角色多对多,角色与菜单权限多对多。
考虑到实验室管理系统的实际规模,用户表不需要设计得过于复杂,核心字段包括:username、password、real_name、phone、email、user_type。这里user_type用于区分“管理员”“教师”“学生”“维修人员”。它的作用是方便页面按类型做默认筛选,但不替代RBAC的权限判断。
角色这一层我预置了三种:系统管理员、实验室管理员、普通用户,外加一个可选的维修人员角色。系统管理员负责系统配置和用户管理,实验室管理员负责审批租赁、派单报修、费用结算,普通用户负责提交申请和报修。权限控制粒度到按钮级别,前端用Vue指令判断按钮显隐,后端接口用@PreAuthorize注解控制访问权限。
注意:前端隐藏按钮只是提升体验,不能作为安全手段。接口层面必须做二次权限校验。我见过不少系统,前端不让点、后端却没拦截,有人直接拿API工具调用接口就能越权操作。所有写操作接口,都要在Service层校验当前登录用户是否具备该操作的角色权限。
3. 租赁与报修核心流程的实现路径
3.1 租赁业务完整链路:从申请到费用结算
租赁流程贯穿六步,每一步在代码层面都需要考虑事务、并发和状态校验。用一张表整理链路最直观:
| 步骤 | 操作人 | 接口 | 核心逻辑 |
|---|---|---|---|
| 1 | 用户 | /api/lease/apply | 创建租赁主单和明细,校验设备状态,状态置为待审批 |
| 2 | 管理员 | /api/lease/approve | 审批通过/驳回,通过则设备状态置为待领取,生成领取码 |
| 3 | 借用人 | /api/lease/receive | 校验领取码,确认领取,设备状态置为已借出 |
| 4 | 借用人 | /api/lease/return | 归还设备,登记归还时间,设备状态置为待归还(等待结算) |
| 5 | 管理员 | /api/lease/settle | 计算租金、超期费、赔偿费,确认结算,订单置为已完成 |
申请提交这一步,最容易出的并发问题是:同一台设备同时被两个人申请。如果在Service里先查询设备状态是“空闲”,再插入租赁单,两个请求同时进来时,两次查询都可能拿到“空闲”,然后都成功创建订单,设备被重复预约。
解决方式有两种,我建议两个都做:
- 乐观锁:设备表加version字段,申请时update ... set status = 4, version = version + 1 where id = ? and version = 旧值。影响行数为0说明并发冲突,直接提示“设备被他人抢先预约”。
- 唯一约束:当同一时间只允许一个有效租赁单时,可以在租赁明细表加一个函数索引或唯一索引,限制同一设备不能存在多个状态为0/1的订单。MySQL 8.0支持函数索引,也可以用一个冗余字段设备状态来做约束。
审批通过接口,事务上要保证两步的一致性:更新订单状态为“审批通过”,同时更新设备状态为“待领取”。一旦第二步失败,整个事务回滚,订单和设备的变更都不会落库。这里推荐在Service方法上加@Transactional(rollbackFor = Exception.class),注意默认只回滚RuntimeException,如果业务方法抛的是自定义CheckedException,必须显式指定rollbackFor,否则会出现部分提交的尴尬局面。
领取设备的实现要点是领取码。审批通过时系统生成6位随机数字领取码(有效期48小时),通过接口返回给借用人。线下领取时,管理员核对身份证/学生证和领取码。这个设计是为了避免“审批通过后,任何人只要知道订单号就能领取设备”的问题。
归还登记时,要检查归还设备是否有未完结的报修单。如果设备在借出期间产生了报修单,归还时要提醒管理员:“该设备存在未完结报修单,请确认修复后再接收归还。”这一步目前是提醒级别,如果学校制度严格,可以直接拦截,强制先完结报修再归还。
费用结算这块,我的建议是结算明细要让管理员看得懂。settle接口返回一个结算单对象,包含:日租金单价、租赁天数、正常租金、超期天数、超期单价、超期费用、维修赔偿费用、合计金额。每个字段单独给前端展示,不要只给一个总金额,否则管理员根本不知道这个总金额是怎么算出来的,对账时只能靠猜。
3.2 报修业务完整链路:从提交到验收
报修链路相比租赁更简单,但同样有隐蔽的坑。
| 步骤 | 操作人 | 接口 | 核心逻辑 |
|---|---|---|---|
| 1 | 用户 | /api/repair/submit | 选择设备,填写故障描述和照片,创工单,设备状态置为维修中 |
| 2 | 管理员 | /api/repair/dispatch | 指派维修人员,工单状态置为维修中 |
| 3 | 维修人员 | /api/repair/finish | 填写维修结果和费用,提交待验收 |
| 4 | 管理员/报修人 | /api/repair/accept | 验收通过,工单关闭,设备状态按情况恢复 |
| 5 | 管理员 | /api/repair/reject | 验收不通过,退回维修人员重新处理 |
提交报修时最容易被忽略的是:设备状态为“已借出”时,能否报修。实际场景中设备在租用期间损坏的案例不少。所以我设计为任何非报废设备都能提交报修,但设备状态不同,提交后的流转不同:
- 设备空闲时提交报修,状态从空闲直接改为维修中。
- 设备已借出时提交报修,设备状态保持已借出。
- 报修单创建后,系统给当前借用人发提醒:“您借用的设备已被报修,请尽快归还检查。”
设备状态是否改为维修中,会直接影响租赁模块“能否继续被预约”的判断。如果设备已借出但被报修,此时设备状态不能改成维修中,否则租赁模块会在借用人归还时把状态从维修中改成待归还,造成状态错乱。
派单逻辑这里我做了个小优化:后台列表展示待派单时,默认按“紧急程度”和“报修时间”排序,紧急且有图片的先显示。人工派单而不是自动派单的原因很简单:派单给谁,取决于维修人员的技能方向、当前工作量、是否在实验室现场,这些信息系统里不一定全,所以人工决策最靠谱。
维修人员提交维修结果时,维修费用默认为0,管理员在验收时可以修改。验收不通过时,工单状态退回“维修中”,设备状态保持不动,直到真正验收通过。验收通过后,设备状态是否恢复为空闲,要先查该设备是否有还未完结的租赁单。
流程图之类的东西我就不画了,但这个状态流转规则直接写成代码里的枚举校验方法,每次状态更新前调用,非法跳转直接抛业务异常。这是在真实项目里踩了多次坑后才总结出来的铁律。
3.3 定时任务与提醒机制
系统里有两个定时任务非常重要,一个是租赁超期提醒,一个是设备巡检提醒。
租赁超期提醒的逻辑是:每天凌晨执行一次定时任务,扫描所有状态为“租赁中”且expected_return_date小于当前日期的订单,给借用人推送短信/站内信,并在管理后台生成“逾期未还列表”。之前用过Quartz,后来发现这个任务简单到用Spring自带的@Scheduled就能搞定,表达式cron = "0 0 2 * * ?"每天凌晨两点执行。为什么选凌晨两点而不是零点:零点附近常常有日切任务、数据库备份任务,错峰执行能减少资源竞争。
设备巡检提醒则是按设备类别配置巡检周期,比如精密仪器每30天提醒一次,普通设备每90天提醒一次。定时任务扫描所有设备,距离上次巡检时间超过配置周期的设备,给实验室管理员发提醒。
定时任务里有一个开发时容易踩的坑:在@Scheduled方法里直接调用另一个类的@Transactional方法,事务会失效。因为定时任务类本身不是通过Controller进入的,Spring AOP代理链路不完整(其实本质上还是自调用问题)。我的做法是:定时任务类只做数据扫描和组装消息,真正的事务处理放在XxxService中,由定时任务类注入Service后调用Service的方法。这样就避免了自调用导致的事务代理失效问题。
注意:@Scheduled默认是单线程串行执行的。如果项目里有多个定时任务,而且某个任务执行时间很长,其他任务会被阻塞直到上一个完成。如果任务数量多,建议配置@EnableAsync或者自定义TaskScheduler的线程池大小。
4. 安全与权限:登录认证、接口对接与数据加密
4.1 JWT登录认证与Token鉴权落地
系统采用Spring Security + JWT做登录认证。流程不复杂:用户提交用户名密码,后端校验通过后生成JWT Token返回给前端,前端每次请求带着Authorization: Bearer token头,后端通过OncePerRequestFilter拦截请求,解析Token并设置当前登录用户上下文。
配置Spring Security时需要注意的细节:放行路径清单必须严格控制,只放行登录接口、验证码接口、Swagger文档路径,其余所有接口一律认证。放行路径写多了等于把系统裸奔,写少了前端联调时天天报401。
Token有效期设置,这个一定要想清楚。实验室设备管理系统的用户主要是老师和学生,使用频率不算极端高。我设的是Token有效期2小时,再加上Redis里的Session记录,用户刷新页面时如果Token过期,走刷新逻辑(前端拿refresh token换新token)。如果你做的是更简单的内部系统,也可以直接设置7天有效,但每增加一天有效期,Token泄露后的风险窗口就多一天,权衡后我认为2小时比较稳妥。
JWT的密钥要放在配置文件中,不要硬编码在代码里。实际项目里我还会让密钥长度超过32字节,防止签名算法(HS256)因为密钥过短而被暴力破解。这个点很隐蔽,但确实属于安全基线检查项。
4.2 API Key对接场景:第三方系统安全接入
项目里有对接学校统一门户和外部教务系统的需求。第三方系统的服务端要调用我们设备信息查询接口,但第三方系统没有用户session,不能走JWT登录。这就要用到API Key机制。
设计思路是:管理员在后台给第三方系统生成一对API Key和Secret。调用方每次请求时,使用API Key生成签名:把请求参数、时间戳、Secret一起拼接后做HMAC-SHA256哈希,将API Key和时间戳放在Header中,服务端用相同逻辑计算签名并比对,同时校验时间戳与服务器当前时间差不超过5分钟,防止重放攻击。
有了这套逻辑,就不存在“避开Token验证”的问题了——API Key本身就是一种替代Token的认证方案。具体实现选型上,可以直接在Spring Boot中通过HandlerInterceptor实现,也可以基于已有的Sa-Token等框架快速实现。我用的是自定义过滤器,成本很低,而且不侵入现有Spring Security配置链。
注意:API Key的Secret只能存储在服务端,绝不能出现在前端代码里。第三方接入方如果泄露了Secret,管理员在后台一键重置即可。每次重置后,旧签名自动失效,不需要改任何代码。
4.3 数据加密与配置安全
项目里涉及数据库密码和部分敏感配置,我引入了Jasypt对配置文件中的密码做加密处理。配置文件中不出现明文密码,而是ENC(加密串)。启动时通过Jasypt的StringEncryptor解密,Spring的配置环境自动加载解密后的值。这套机制不复杂,但能堵住“代码仓库泄露后数据库密码跟着一起泄露”的大坑。
网上看到有人问“数据库用户密码采用SM4加密方式并且在JasyptStringEncryptor中”,这里的思路是:Jasypt默认支持PBEWithMD5AndDES等对称算法。如果企业要求强制使用国密SM4,就自定义StringEncryptor实现类,内部调用SM4工具类,加密后的密文再包成ENC()格式,Jasypt在解析属性时会自动走自定义实现类解密。这个改造逻辑不复杂,关键在于不要让Jasypt的默认算法和国密算法混用,要么全部默认、要么全部自定义,否则运维会一头雾水。
5. 文件上传下载与Minio资源映射
5.1 Minio在设备管理里的应用场景
设备系统里文件上传的场景其实不少:设备照片、租赁合同扫描件、故障照片、维修结果附件。这里我用Minio做对象存储,而不是把文件直接存在本地磁盘或者数据库里。
本地磁盘存储的问题很直接:项目改代码重新部署时,文件还留在原来的路径,一旦清理临时目录或者迁移服务器,附件就丢了。数据库存二进制文件则会让表变得巨大,备份和查询都很痛苦。Minio是私有化部署的对象存储,文件上传后返回一个对象名称,业务表只存对象名称或访问路径。
Minio的核心配置也就几项,application.yml里如下:
yaml复制minio:
endpoint: http://192.168.1.100:9000
access-key: minioadmin
secret-key: minioadmin
bucket-name: lab-device
注意,MiniIO的访问凭证不推荐用默认的minioadmin/minioadmin,生产环境一定要改,且将这个配置文件用Jasypt加密。Bucket的访问权限我设置为“私有”,所有文件访问都通过后端生成预签名URL来实现,而不是把Bucket设成公共读。公共读的Bucket一旦有文件路径泄露,任何人都能直接下载,涉及学生信息、设备合同的文件绝不能这么干。
5.2 大文件上传与断点续传方案
实验室设备有时需要上传高清图片、维修视频,体积可能达到几百MB甚至1GB。直接走普通的BASE64上传或者普通MultipartFile上传,会导致前端请求超时、后端内存溢出,Tomcat默认的单次上传大小也根本扛不住。
我采用的方案是:前端分片 + Minio预签名URL直传。
流程拆解:
- 前端把文件切成固定大小分片,比如每片5MB。
- 后端提供初始化接口,接收文件名、文件大小、分片数量,生成上传任务ID。
- 后端向Minio申请一个预签名上传URL(有效期10分钟),返回给前端。
- 前端直接通过PUT请求把分片传到Minio的预签名URL,不经过应用服务器。
- 所有分片传完后,前端调用合并接口,后端调用Minio的composeObject方法把分片合并成完整文件。
- 后端记录文件对象名,与业务ID绑定。
这个方案最大的好处是文件数据流不经过应用服务器,后端只是下发预签名URL和调度分片,因此哪怕同时有几十个人上传1GB文件,应用服务器的内存和带宽压力也基本没有。
预签名URL的过期时间要结合分片大小和用户网速调整。5MB一片,如果用户上传带宽只有1MB/s,一片要5秒,10分钟有效期足够传100多片,正常场景够用。如果确实有超慢速网络,可以在合并前再申请一次预签名URL,把未传完的分片继续传完。
注意:注意分片大小建议取整数,并且分片序号从0开始还是从1开始要和Merge时的拼接顺序保持一致。我踩过的坑是分片上传时倒序返回前端列表,Merge时按文件名默认排序,结果文件内容被倒序拼接,视频文件直接损坏。
5.3 资源映射与静态文件访问
Minio里的文件访问路径,很多新手直接在前端拼一个http://ip:9000/bucket/xxxxx的地址来访问,这在Bucket是私有权限时根本行不通。正确的做法是后端提供接口,根据文件对象名生成带签名的临时访问URL,前端拿到后直接访问。URL默认有效时间我设置为10分钟,足够图片加载完成。
还有一个场景是个别不需要走Minio的小文件(比如用户头像),直接放在服务器本地路径,此时需要做资源映射。Spring Boot中通过WebMvcConfigurer的addResourceHandlers方法,把本地磁盘路径映射为URL路径:
java复制@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/upload/**")
.addResourceHandler("file:/data/lab/upload/");
}
}
这个配置有个坑:如果文件是中文名,直接映射访问会被Tomcat当成非法字符(RFC 7230规范限制),浏览器访问时URL要编码之后才能正常打开。所以业务开发中建议上传到存储时的对象名一律使用UUID或日期+随机数,不保留中文名。中文原文件名单独存一个字段,提供下载接口时返回原始文件名作为Content-Disposition的头信息。
6. Docker化部署与常见问题排查实录
6.1 JDK 1.8 + Spring Boot项目打包到Docker Desktop
Spring Boot打包和部署,最顺手的方案是Docker。这里分享一下JDK 1.8 + Spring Boot项目打包到Docker Desktop的完整路径,这个过程有不少细节容易卡住。
项目用Maven构建,先执行打包命令:
bash复制mvn clean package -DskipTests
打包成功后在target目录下生成jar包。之后写Dockerfile:
dockerfile复制FROM openjdk:8-jdk-alpine
LABEL maintainer="lab-dev"
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
COPY target/lab-device-system-1.0.0.jar /app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app.jar", "--spring.profiles.active=prod"]
这里有几个关键点:
- 基础镜像用openjdk:8-jdk-alpine,体积小(100MB左右),部署拉取快。但要注意:alpine基础镜像里的时区可能默认是UTC,必须加ENV TZ和时区软链,否则日志时间比北京时间慢8小时,排查线上问题时非常误导。
- Dockerfile里的jar包名必须和target目录里的实际产物一致,否则build时直接复制不到文件报错。
- 如果项目依赖的MySQL和Redis跑在宿主机上,容器启动时要加--network=host,或者用--add-host给容器加宿主机IP映射。Docker Desktop环境下,宿主机是主机上的一个轻量虚拟机,容器内访问宿主机不能用127.0.0.1,要用host.docker.internal这个特殊域名。这是Docker Desktop和纯Linux Docker环境差异最大的地方,新手在这一步最容易卡住。
构建和启动命令如下:
bash复制docker build -t lab-device-system:1.0.0 .
docker run -d --name lab-device -p 8080:8080 \
-e MYSQL_HOST=host.docker.internal \
-e REDIS_HOST=host.docker.internal \
lab-device-system:1.0.0
数据卷方面,如果系统里有本地文件上传需求,记得把宿主机目录挂载进容器,例如-v /data/lab/upload:/data/lab/upload。不挂载的话,容器一旦删除重建,之前本地上传的文件就全部消失了。
6.2 常见问题:版本冲突、循环依赖、事务失效
围绕Spring Boot开发,有些高频问题在群里几乎每天都能看到。我挑几个在这套系统开发过程中实际遇到过的,梳理成速查表:
| 问题现象 | 根因 | 解决方案 |
|---|---|---|
| Maven下载依赖时报Downloading...后一直卡住 | 国内网络访问Maven中央仓库速度慢,或者仓库地址配置错误 | 换阿里云Maven镜像,settings.xml里配置mirrorOf为central |
| IDEA里application.yml不提示配置项 | 项目没引入spring-boot-configuration-processor依赖,或IDEA缓存异常 | pom.xml引入该依赖,重启IDEA并清理缓存 |
| Spring Boot 3.x项目里报ClassNotFound: javax.servlet | Spring Boot 3.0后包名从javax换成jakarta | 要么降级到2.7.x + JDK8,要么把所有import javax.改成jakarta. |
| 启动报循环依赖Depends on circular reference | Spring Boot 2.6起默认禁止循环依赖 | 用@Lazy延迟其中一个Bean,或者重构拆分Service避免相互注入 |
| @Transactional方法内部调用另一个方法,事务没生效 | Spring事务基于AOP代理,同类内部调用不会走代理 | 把调用拆到另一个Service类,或自注入代理对象 |
| MyBatis Mapper接口扫描不到 | 启动类未加@MapperScan,或Mapper接口和Mapper.xml文件不在同一位置 | 在启动类添加@MapperScan("com.example.lab.mapper")并确认xml路径与配置一致 |
| jar包能启动但访问404 | 前后端分离打包后前端dist目录的资源没拷贝进静态资源目录 | 后端单独部署或把前端dist文件复制到Spring Boot的static目录,注意路径重定向 |
| Docker容器里日志中文变问号 | 基础镜像没有中文字符集,或JVM默认编码不是UTF-8 | Dockerfile里加ENV LANG=C.UTF-8,启动参数加-Dfile.encoding=UTF-8 |
这里面最典型的是循环依赖。项目初期,UserService和RepairService都互相需要对方查询数据,直接双向注入导致Spring Boot启动报错。后来我把公共查询逻辑抽到单独的QueryService中,两个业务Service都只依赖QueryService,循环依赖自然消失。抽公共层解决循环依赖,比单纯加@Lazy更健康,因为@Lazy只是延迟代理创建,没有从根本上消除不合理的依赖关系。
事务失效的问题也值得多说一句。Spring事务的本质是AOP代理,如果在一个类的内部方法A中调用同类的方法B,而B上了@Transactional,这个方法B根本不会走代理,事务自然不生效。所以我在团队里立了条规矩:所有需要事务的写操作,一律通过Service接口暴露,不允许在类的内部直接自调用。这条规则能避免90%的“@Transactional没生效”类问题。
6.3 单元测试的落地实践
单元测试这块,说句扎心的话:很多管理系统的项目代码写完就完了,测试几乎没有。但设备租赁这种涉及金额结算的业务,没有单元测试兜底,改一次代码我都不敢上线。所以这个项目里我补了关键模块的单元测试,重点覆盖租赁费用计算和报修状态流转。
费用计算的测试用例,我会单独建一个测试类,用JUnit 5的参数化测试覆盖多种场景:正常租期无超期、超期3天、赔偿费用叠加、租期正好一天、租期跨月、归还时间早于借用时间(异常数据)等。这样每次改费用规则,跑一遍测试就知道有没有改坏旧场景。
java复制@SpringBootTest
public class LeaseSettlementServiceTest {
@Autowired
private LeaseSettlementService settlementService;
@ParameterizedTest
@MethodSource("provideSettlementCases")
public void testCalculateAmount(LocalDate borrowDate, LocalDate returnDate,
BigDecimal dailyRate, BigDecimal expectedAmount) {
BigDecimal amount = settlementService.calculateAmount(borrowDate, returnDate, dailyRate);
assertEquals(expectedAmount, amount);
}
}
状态流转的测试,则直接调用Service方法,用断言确认每一步操作的返回值,以及非法状态跳转时是否抛出对应的业务异常。有了这些测试,后面做权限调整、加状态、改费用规则时,回归成本大大降低。
6.4 IDEA下MyBatis集成Spring Boot的经典报错
最后专门说一个环境坑:IDEA里集成MyBatis时,有时候控制台会一直卡在“Downloading...”状态,然后报错,项目根本起不来。这个问题的根源通常是两个:一是Maven仓库配置错误导致依赖下载不下来;二是IDEA的Maven自动导入没开启,项目没把依赖正确加载进classpath。
解决办法分三步走:
- 检查IDEA的Maven设置,确认User settings file指向的settings.xml存在,且本地仓库路径不包含中文或空格。
- 清空IDEA的缓存和索引:File → Invalidate Caches / Restart,重启后自动重建索引。
- 在pom.xml里加一个可靠的国内Maven仓库镜像,比如阿里云公共仓库,然后点一次Maven面板的“Reload All Projects”。
这些操作做完,绝大多数下载卡死和依赖找不到问题都能解决。如果还报MyBatis绑定异常,那就是Mapper接口和Mapper.xml的namespace或id没对上的老问题,按着报错信息逐行核对XML路径和方法名就行。
7. 实操中沉淀下来的几句实话
整套系统从数据库建模到Docker部署跑通,中间反复改了很多轮。如果要总结一点心得,我最想说的是:业务状态机的设计决定了系统能走多远。租赁和报修这两条主链路里,所有让人觉得“难改”的需求,最后都跟状态设计有关系。一开始就把每个业务的完整生命周期拆清楚,定义好合法状态跳转规则,后面加审批流、加超时取消、加自动结算都只是往这个骨架上挂肉。
另一个很实际的经验是,所有拿不准的规则最好先问清楚再做,而不是先写了再说。比如租金里“超期天数”按自然日还是工作日算,维修费用是否能由报修人自己提交,这些规则宁可多问一句,也不要自己拍脑袋。不然代码写完了,业务方一句“这块我们不这样算”,整个模块就要重新动。
如果你正准备做类似的设备管理或工单系统,我的建议是:先画状态机、再建表、最后写代码,这个顺序千万别倒过来。项目做完了回头看,最值钱的不是那些CRUD接口,而是那一张把业务从头到尾串起来的状态流转图。
