资讯动态

Spring Boot+Vue3仓库租赁管理系统:从数据库建模到部署上线全解析

发布时间:2026/9/29 12:15:23 来源:尧图企业网站定制
做了这么多年管理系统我一直觉得仓库租赁这一行特别有意思。它不像电商系统那样比拼流量和转化率也不像进销存那样围绕货品流转做文章它的核心矛盾在于——空间是固定的时间是流动的租户是变化的账期是交叉的。一个仓库今天租给A明天可能就要退租给B中间的租金怎么算、账单怎么催、到期怎么提醒全是细节。所以我用Spring Boot Vue3从零搭了一套仓库租赁管理系统从数据库建模到前后端联调再到部署上线整个过程走下来踩了不少坑也沉淀了不少经验。这篇就完整拆解一下这套系统的设计思路和落地实现适合正在做毕设、或者公司内部需要做同类管理系统的同学参考。1. 仓库租赁系统最容易被低估的业务建模环节很多人拿到“仓库租赁管理系统”这个需求第一反应就是不就是仓库表、合同表、客户表然后增删改查吗真上手做过就会发现租赁业务和普通商品管理有本质区别——你卖出去一个杯子交易就结束了但仓库的每次出租实际上是把同一物理空间按时间片切分给不同租户的过程。这个“时间维度”一旦进去所有逻辑的复杂度都会上一个档次。1.1 租期、账期与空间状态的时间轴问题租赁业务里面有三个时间概念必须搞清楚合同期合同约定的起止时间比如2024年1月1日到2024年12月31日。账期一次租金结算覆盖的时间段。可能是一个月、一个季度甚至自定义的周期。空间占用期仓库物理上被某个租户占用的时间段。这三个时间段在大多数情况下是一致的但一旦出现提前退租、中途续租、逾期未搬离时间轴就会出现错位。比如租户合同签到12月31日但11月20日就提前搬走了那11月的账单是按整月收还是按天折算租户1月1日签合同但仓库实际1月10日才腾出来这10天算谁的这时候如果程序里的“仓库状态”只是一个简单的“空闲/出租中”字段根本处理不了这些情况。我当时的做法是仓库状态只做展示用真正的判断逻辑全部基于合同时间轴——查询某日期范围内仓库是否可租直接查合同表里有没有时间重叠的生效合同。这样哪怕状态字段更新出错业务判断也不会出错。1.2 租金不是一个数字而是一套计算规则租金是最容易被做成“死字段”的地方。很多初版设计会在合同表里放一个monthly_rent字段每月租金直接填数字。但实际业务里租金规则至少有这几种固定月租小仓库常用按面积计费单价×平方米比如1.5元/平方米/天阶梯计价面积超过一定阈值单价下浮周期内折扣年付打九折季度付九五折。如果只存一个最终数字后续做账单拆分和统计报表会很痛苦。我的方案是合同表里存计费模式billing_type同时保留计费单价unit_price和计费单位price_unit按天/按月/按面积每月账单生成时根据这些字段动态计算金额。仓库表只存基础信息名称、地址、总面积租金计算完全走合同内的规则配置。2. 数据库设计把租赁关系落成可回溯的表结构数据模型是整个系统的地基这里设计得不好后面写代码处处难受。我最终的表结构里核心表是这五张仓库表、客户表租户表、合同表、账单表、收款记录表另外加一张操作日志表用来审计关键动作。2.1 核心表的字段设计与关键索引仓库表warehouse字段类型说明idbigint主键namevarchar(100)仓库名称addressvarchar(255)仓库地址areadecimal(10,2)总面积平方米available_areadecimal(10,2)可用面积statustinyint0空闲 1部分出租 2已出租remarkvarchar(500)备注created_timedatetime创建时间一个仓库可能只租出一部分面积所以单独留了available_area字段便于做剩余面积统计和看板展示。合同表rental_contract字段类型说明idbigint主键contract_novarchar(32)合同编号唯一索引customer_idbigint客户IDwarehouse_idbigint仓库IDrented_areadecimal(10,2)租赁面积start_datedate合同开始日期end_datedate合同结束日期billing_typetinyint计费模式unit_pricedecimal(10,2)计费单价price_unittinyint计价单位 0按天 1按月 2按面积depositdecimal(10,2)押金statustinyint0草稿 1生效中 2已到期 3已终止sign_timedatetime签订时间pdf_urlvarchar(200)合同扫描件地址注意几个细节合同编号加唯一索引因为线下对账、开发票都以这个编号为准绝不允许重复start_date和end_date用date类型而不是datetime因为租赁的粒度是天加上了时间反而容易出边界判断错误。账单表settlement_bill字段类型说明idbigint主键contract_idbigint所属合同bill_novarchar(32)账单编号唯一索引period_startdate账期开始period_enddate账期结束amountdecimal(12,2)应收金额paid_amountdecimal(12,2)已收金额statustinyint0未支付 1部分支付 2已支付due_datedate缴费截止日late_feedecimal(10,2)逾期滞纳金建表时有个特别容易犯的错金额字段用float或double做。租金计算涉及乘法和累加浮点数误差会在账目上积累出“差几分钱”的尴尬问题。金额一律用decimal计算用BigDecimal这个没得商量。2.2 为什么账单必须单独成表而不是塞在合同里我第一次设计时想过直接在合同表里加rent_amount字段账单列表从合同里取但很快发现行不通——因为一份合同会产生多张账单而且每张账单的账期不同、状态不同。合同是1年期的按月付款就有12张账单租户可能3月份没交钱4月份交了两个月两个月后再补3月的滞纳金这种一对多的关系不拆表根本没法查。账单单独成表之后统计月报也简单了按period_start和period_end过滤账单再按status分组汇总应收和实收前端图表要的月度趋势数据直接SQL搞定不需要再写一堆业务代码去拼。3. Spring Boot后端合同状态流转与租金结算的实现后端用Spring Boot 2.7版本ORM用的MyBatis-Plus权限认证用的是Spring Security JWT。这套组合胜在各层面都有成熟方案遇到问题网上一搜一大片对个人开发和中小团队非常友好。3.1 基于状态机的合同全生命周期管理合同状态只允许四个草稿、生效中、已到期、已终止。表面上看用一个status字段就够了但问题是状态之间不是随便跳的——草稿必须经过审核才能变成生效中生效中只有到了结束日期才能变成已到期提前退租则必须走终止流程已终止的合同不能重新变成生效中。状态变更我做了一个统一的ContractStateService所有流转都走它的方法public void changeStatus(Contract contract, ContractStatus targetStatus) { // 校验合法性 if (contract.getStatus() ContractStatus.DRAFT targetStatus ContractStatus.EFFECTIVE) { // 草稿 - 生效检查合同起止日期和押金 if (contract.getStartDate().isAfter(LocalDate.now())) { contract.setStatus(ContractStatus.EFFECTIVE); } } else if (contract.getStatus() ContractStatus.EFFECTIVE targetStatus ContractStatus.TERMINATED) { // 生效 - 终止需要记录终止原因和提前退租日期 contract.setTerminateReason(terminateReason); contract.setActualEndDate(LocalDate.now()); } // 其它非法流转直接抛异常 contractMapper.updateById(contract); // 写操作日志 auditLogService.record(contract, contract.getId(), 状态变更为 targetStatus.getDesc()); }之所以要集中管理而不是在Controller里直接改status是为了防止后面加需求比如终止时自动生成退租结算单时逻辑散得到处都是。状态机的思想就是所有变更都是经过同一个入口所有副作用都挂在同一个事务里这样即使出了bug排查链路也是清晰的。3.2 租金的自动结算与逾期处理账单生成我用的定时任务每天凌晨1点跑一次。任务逻辑分两块下一账期账单生成对于生效中的合同判断当前是否到了一个新的账期起点如果到了就按合同规则生成新账单逾期滞纳金计算对所有状态为“未支付”或“部分支付”的账单检查due_date是否已过如果过了就按每日千分之五计算滞纳金并更新账单的late_fee字段。核心代码如下Component public class BillSettlementTask { Scheduled(cron 0 0 1 * * ?) public void settleBills() { // 1. 生成新账单 ListContract activeContracts contractMapper.selectList( new LambdaQueryWrapperContract() .eq(Contract::getStatus, ContractStatus.EFFECTIVE)); activeContracts.forEach(this::generateBillIfNeeded); // 2. 计算逾期滞纳金 ListSettlementBill overdueBills billMapper.selectList( new LambdaQueryWrapperSettlementBill() .in(SettlementBill::getStatus, BillStatus.UNPAID, BillStatus.PARTIAL_PAID) .lt(SettlementBill::getDueDate, LocalDate.now())); overdueBills.forEach(bill - { long overdueDays ChronoUnit.DAYS.between(bill.getDueDate(), LocalDate.now()); BigDecimal lateFee bill.getAmount() .multiply(new BigDecimal(0.005)) .multiply(new BigDecimal(overdueDays)); bill.setLateFee(lateFee); billMapper.updateById(bill); }); } }这里有个容易踩的坑定时任务里如果有大量DB操作一定要记得分批处理。租户多的时候一次性扫全表没问题但一旦合同数据上了万级建议用LIMIT分页循环查避免单次事务时间过长锁表。3.3 到期提醒与消息通知的实现思路系统里到期提醒我用的是Spring的Scheduled定时扫描 WebSocket推送。每天早上8点扫一遍所有生效中的合同找出距离end_date还剩30天、7天、3天的合同给对应管理员推送站内信有企业微信或钉钉的还可以加Webhook。推送代码如下Scheduled(cron 0 0 8 * * ?) public void remindExpiringContracts() { LocalDate today LocalDate.now(); ListContract expiringContracts contractMapper.selectList( new LambdaQueryWrapperContract() .eq(Contract::getStatus, ContractStatus.EFFECTIVE) .and(wrapper - wrapper .eq(Contract::getEndDate, today.plusDays(30)) .or().eq(Contract::getEndDate, today.plusDays(7)) .or().eq(Contract::getEndDate, today.plusDays(3)))); for (Contract contract : expiringContracts) { String msg String.format(合同[%s]将于%s到期请及时处理续租或退租, contract.getContractNo(), contract.getEndDate()); noticeService.push(NoticeType.EXPIRING, contract.getId(), msg); } }为什么用扫库而不是用消息队列延迟消息坦白说一个仓库租赁系统的并发量远没到需要上MQ的级别定时任务完全够用而且逻辑直观、好排查。技术选型要匹配业务场景不要为了架构而架构。4. Vue3前端从列表页到可视化看板的落地细节前端用的是Vue3 Vite Pinia Element Plus ECharts。这套组合在我做过的多个后台系统里是最顺手的构建速度、组件丰富度、状态管理体验都在线。4.1 为什么选择Vite Element Plus Pinia这套组合Vite在开发环境的冷启动速度和热更新体验比Webpack好一大截配置也简单。Pinia相比Vuex去掉了很多概念性负担mutations、modules嵌套等写起来更像普通的store定义配合组合式API非常自然。Element Plus对Vue3的适配做得最完善表格、表单、日期选择器、弹窗开箱即用对于后台管理系统能省下大量造轮子时间。依赖安装很简单npm create vitelatest warehouse-front -- --template vue cd warehouse-front npm install element-plus pinia vue-router axios echartsmain.js里做全局注册Element Plus用完整引入还是按需引入我推荐开发阶段直接全量引入省事等部署时再通过unplugin-vue-components做按需自动导入。项目的核心诉求是快速跑通业务别在构建配置上消耗太多精力。4.2 合同操作的交互设计与动态表单处理租赁合同的新建表单是整个前端最复杂的部分因为它涉及客户选择下拉搜索、仓库选择要带出租金单价和可用面积、起止日期限制结束日期不能早于开始日期、计费模式切换不同模式显示不同的单价输入框。Vue3组合式API写这种动态表单非常舒服。用computed根据当前选中的计费模式动态生成表单规则const billingType ref(1) const unitPriceLabel computed(() { const map { 0: 按天单价元/天, 1: 按月单价元/月, 2: 按面积单价元/平方米/天 } return map[billingType.value] })表单校验交给Element Plus的rules日期范围校验用自定义validator确保结束日期不能在开始日期之前。提交前把rented_area和unit_price相乘做个金额预览让操作员在保存之前就能看到租金大概是多少这个小交互在真实使用中特别被好评。4.3 仓库利用率看板与数据可视化首页看板用ECharts做了三张图仓库月出租率趋势折线图按每月出租面积/总面积计算应收与实收月度对比柱状图蓝色柱是应收绿色柱是实收合同到期分布饼图统计未来30天、60天、90天内到期的合同数量。ECharts在Vue3里最简单的用法是直接div refchartRef在onMounted里初始化实例并setOption数据响应式更新时调用实例的setOption方法。这里注意一个问题——组件销毁时记得dispose图表实例页面切换多了之后不释放实例会撑爆内存页面白屏了才知道难受。5. 联调部署阶段的常见坑与性能优化前后端联调和部署上线遇到的问题往往比写业务代码时踩的坑更隐蔽。这里把我实际碰到的几个典型问题列一下每个都值得提前注意。5.1 前后端联调的时间格式与精度问题最经典的问题是时间。后端LocalDateTime默认序列化出来是2024-01-01T12:00:00这种格式前端的日期选择器用的是2024-01-01直接对接会解析报错。解决方案是全局统一Jackson配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8另一个精度问题是BigDecimal传输到前端后JS的Number类型可能丢失精度。即便是租金这种金额如果是乘出来的结果比如1587.33元前端拿去展示没问题但如果前端再参与计算比如减去已付金额就可能变成1587.3300000000002。我最终的方案是所有金额在后端计算好前端只负责展示前端传给后端的金额一律是最终确定值不再参与二次运算。这样最省心。5.2 跨域配置与嵌套事务的隐患开发环境下前端跑localhost:5173后端跑localhost:8080必然有跨域问题。在网关或后端配置CORS时注意不要用allowedOrigins(*)配合allowCredentials(true)这是不合法的组合浏览器会直接拦截。稳妥做法是指定允许的来源Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOrigin(http://localhost:5173); config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }另外事务这块要特别小心。当时合同终止功能里我先更新合同状态再生成退租账单再记录操作日志三个方法都加了Transactional注解结果内部调用的时候事务没生效——因为Spring只是在代理对象上生效同类内部直接方法调用是不会走代理的。后来我把三个方法拆到不同Service或者在原Service里注入自身代理Lazy自注入问题才解决。这个坑网上讨论得很多轮到自己写还是会踩。5.3 部署配置与线上安全加固部署方案是极端简单的后端打jar包放到服务器上用nohup java -jar跑前端npm run build生成静态文件交给Nginx托管同时Nginx把/api路径反代到后端的8080端口。Nginx关键配置location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }上线前做安全加固时有几件事是必须做的改端口Spring Boot默认8080太容易被扫描到我改成了不常用的高端口数据库账号权限给系统单独建一个账号只授权业务库的增删改查别用rootJWT密钥生产环境的密钥不要写在代码里走环境变量注入避免代码泄露连带token可伪造定时任务开关多实例部署时定时任务会重复执行直接在配置里加开关或者只在单实例服务上跑定时任务。启动脚本可以参考#!/bin/bash nohup java -jar warehouse-system.jar \ --spring.profiles.activeprod \ --app.jwt.secret${JWT_SECRET} \ /data/logs/warehouse.log 21 部署完成后第一件事就是检查健康接口和日志输出确保没有初始化报错。我习惯在启动后访问一次看板接口确认数据库连接池、Redis如果有都正常再让业务人员开始录入数据。最终收尾做完这套系统再回头看我最深的体会是仓库租赁管理系统的难点从来不在某个高深的技术而在于把租赁这种“空间×时间”的复杂业务用清晰的数据结构和稳定的代码逻辑表达出来。时间轴、状态机、账期拆分、金额精度每一个点单拎出来都不难合在一起就容易顾此失彼。最后分享一个我今天还在用的小技巧开发阶段把定时任务的cron表达式调成每分钟执行一次配合日志观察账单生成和滞纳金计算逻辑是否正常等确认没问题了再改回每天执行的节奏。这样调试效率比手动调接口高得多而且不容易漏掉边界情况。后续如果想扩展线上签约、电子发票对接甚至接入GIS地图做仓库可视化选址这套系统的数据基础都是够用的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑