
做过企业信息化项目的朋友应该都有共鸣资产管理系统看起来人畜无害不就是把Excel换成网页吗可一旦进入真实业务场景这玩意儿能把人折磨到怀疑人生。我接手企业资产信息管理系统这个源码项目时对方的日常管理还停留在纸质单据加Excel三套账并行的状态行政、财务、使用部门各记各的一盘点就全面崩溃。这套系统要解决的就是这个核心问题让每一件固定资产从入库到报废所有环节都沉淀成可查、可追、可统计的数据。我采用的技术组合是Spring Boot MyBatis Vue MySQL。不是说这套组合有多先进而是对企业内部管理系统这个场景来说它最稳、最容易维护、也最好招人。整篇文章会把需求拆解、数据库设计、核心功能实现、部署启动流程以及我真实踩过的坑全部过一遍。你不光能看到怎么做还能知道为什么这么做。考虑到读者情况不同我把文章做了分层想快速体验整套系统的可以直接跳到第3章的部署部分准备拿源码做二次开发的建议重点看第2章表结构和第3章状态流转逻辑已经在做类似资产管理系统的朋友第4章的排坑实录应该能提供一些思路。1. 需求分析与整体设计别急着写代码先捋清业务1.1 企业资产管理的核心痛点做企业内系统最怕的就是把线下流程原封不动搬上线结果业务还是乱的。资产管理在大多数企业内部长期处于一种没人说得清的状态行政部门收起购发票和入库单财务部门记一套固定资产折旧账各业务部门自己心里又有一本大概在哪里的使用账。三套账互相之间对不上一到盘点就靠人肉跑到每个工位去核对资产标签。我举几个自己亲眼见过的场景它们直接决定了需求清单。第一个场景是资产标签形同虚设。很多公司给设备贴了标签但标签上只有一串编号查不到对应信息。员工离职时他领用的笔记本电脑往抽屉里一塞或者直接转给同事行政完全不知情。一年之后这台设备就变成了账上存在但谁也找不到的资产。第二个场景是借用什么的全靠口头约定。跨部门借用投影仪、相机、车辆通常就是在群里说一声甚至直接去仓库拿走。登记不存在的。等行政盘点时问起来才有人想起来上个月好像借出去用过后来好像还了不确定。第三个场景是折旧和报废对不上账。财务要求按月折旧行政需要知道每台设备的购入日期和原值但Excel台账里这些字段经常是空的。如果彻底报废一批设备还要走审批流程把资产卡片状态改成已报废很多人嫌麻烦直接不管它导致系统里一堆僵尸资产。这三个场景说明资产管理的本质不是记录资产存在而是记录资产从进到出的每一次状态变化。所以系统的第一个设计原则就是一切以资产卡片为唯一依据任何状态变更都必须有对应的业务单据。没有单据的状态不能变这是后面所有功能设计的核心约束。1.2 功能模块与用户角色拆解按业务场景拆出来的模块我用表格整理如下。这个表格也是我后来给管理人员讲解系统时的底稿模块核心功能日常使用者资产台账卡片新增/编辑/作废/批量导入/导出资产专员资产分类分类树维护分类码参与编号规则系统管理员部门与人员部门树、员工名单维护系统管理员领用归还员工提交申请、管理员审批、归还登记所有人维修管理故障申报、维修记录、费用登记资产专员盘点管理盘点计划、扫码录入、盈亏报告资产专员统计报表部门/分类/状态维度统计、折旧导出管理层系统管理账号、角色、权限、操作日志系统管理员模块划分有两条原则要特别注意。第一基础数据模块和业务模块要分开管理不要把所有维护入口堆在同一个页面里。我见过不少系统把部门、人员、分类全部塞进系统设置里看起来页面少了实际上每次新增资产都要去好几个地方切来切去体验很差。第二每个业务模块必须有独立的列表页和详情页方便后续加审批流。我在第一版里把领用和维修全塞在资产详情页的Tab里后来想单独加一个我提交的申请页面发现数据模型绑得太死重构浪费了一个多星期。角色权限也要在这里定清楚。我采用三种角色系统管理员、资产专员、普通员工。系统管理员负责一切配置和用户管理资产专员负责资产台账、出入库、盘点、报表这些业务操作普通员工只能查询资产和提交领用申请。审批权要单独划出来给部门负责人因为资产专员不能既当运动员又当裁判员——他如果既能发起领用又能自己审批那资产的去向就没人能监督了。实操中你会发现权限设计这件事一开始不做后面想补就会非常痛苦。因为所有前端页面、后端接口、数据库查询都要带上权限过滤不是简单加个字段的事。宁可上线前多花一天去梳理角色也别等上线后花一周去补权限。这里我踩过很深的坑后面详细说。1.3 技术选型为什么用这套组合技术栈选择这事我在前面提过一嘴这里展开说说为什么是Spring Boot MyBatis Vue MySQL。第一点是动态查询的需求太突出了。资产台账的查询条件有十几个包括名称模糊查、编号精确查、分类下拉、部门下拉、状态下拉、日期区间、价格区间组合出来的查询条件有几十种。MyBatis的XML里用where加上一堆if标签就能轻松处理动态SQL维护成本很低。如果用JPA这种多条件组合查询要么写Specification要么拼Query到最后还是一堆动态SQL代码可读性反而更差。第二点是前后端分离带来的部署灵活性。前端Vue工程可以单独打包成静态文件扔到Nginx里直接对外提供服务后端Spring Boot打包成独立jar包跑在服务器上两边互不干扰。企业内网环境经常会遇到只给一台低配服务器的情况这种结构对服务器资源的要求很友好。第三点是生态成熟、人才好找。企业内部系统的特点是开发期短维护期长随着人员流动后来接手的人能不能快速看懂代码比技术上多炫酷更重要。Spring Boot Vue的组合在各类技术社区里的资料极多遇到问题搜索效率高新人上手也快。当然这套组合也有它的短板比如MyBatis对复杂报表的聚合查询支持一般需要手工写很多SQL。我的应对是报表层面单独做一个查询服务不让报表SQL散落到各个Mapper里统一管理。至于前端Vue 2现在Vue 3已经很成熟了新项目可以上Vue 3但Vue 2的Element UI组件生态仍然够用核心页面逻辑完全不需要改动。2. 数据库设计与业务规则表结构是系统的地基2.1 核心资产表结构详解先看最重要的资产信息表。我最初设计时加了很多冗余字段比如直接在表上建了部门名称、分类名称的冗余列结果部门改名或分类改名的时候需要写一条更新SQL把所有资产一起更新。后来我把这些冗余字段全部去掉只存外键ID查询时再关联名称。这个改动让数据维护逻辑少了一大半。CREATE TABLE asset_info ( id BIGINT AUTO_INCREMENT PRIMARY KEY, asset_code VARCHAR(50) NOT NULL COMMENT 资产编号, asset_name VARCHAR(200) NOT NULL COMMENT 资产名称, category_id BIGINT NOT NULL COMMENT 分类ID, spec_model VARCHAR(100) COMMENT 规格型号, serial_no VARCHAR(100) COMMENT 出厂序列号, department_id BIGINT COMMENT 使用部门ID, custodian VARCHAR(50) COMMENT 保管人, location VARCHAR(200) COMMENT 存放地点, purchase_date DATE COMMENT 购入日期, original_price DECIMAL(12,2) COMMENT 原值, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态见状态字典, remark VARCHAR(500) COMMENT 备注, created_by VARCHAR(50), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT NOT NULL DEFAULT 0 COMMENT 逻辑删除, UNIQUE KEY uk_asset_code(asset_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;几个容易被忽略的点一定要说一下。第一asset_code必须加唯一索引。这是系统最重要的约束批量导入的时候哪怕程序有bug数据库也能拦住重复编号。第二deleted字段做逻辑删除不要物理删记录。因为资产上的领用、维修记录都有外键指向资产ID物理删除会把历史流水关联全弄断盘点追溯就没法做了。第三original_price用DECIMAL(12,2)严禁用FLOAT或DOUBLE。金额字段用浮点类型累积下来差一分钱是很常见的事而且这种误差还特别难查。我们之前一个项目就是吃了这个亏对账差了8分钱查了一下午。2.2 资产编号规则与状态机设计资产编号我设计的是分类码年份四位流水号的结构比如IT-2024-0001表示IT类2024年录入的第1条资产。分类码从分类表读取年份取当前年份同一分类同一年的流水从1开始。这个规则的优点是看到编号就能猜到资产大概类型和购入年份打印标签贴在设备上也方便人工识别。这里有个隐含的坑同一分类下的流水号怎么安全自增早期的做法是查当前最大编号再1但因为并发问题出现过重复的资产编号。正确做法是建一张编号流水表专门记录每个分类每年的当前流水号用UPDATE counter SET seq seq 1 WHERE category_code ? AND year ?的方式取号数据库的行锁保证这个操作是原子性的。状态机是整个系统的核心规则。我用了四位数字表示资产状态状态值含义允许的下游操作0在库领用、维修、报废1已领用归还、维修2维修中领用修复后、报废3已报废无状态迁移的逻辑需要在Service层做严格校验不能只在前端按钮上隐藏。后端如果发现调用的操作不符合当前状态直接抛业务异常。比如一台状态为已报废的资产绝不允许再走领用流程。线上出现过有人通过直接抓包调接口、绕过前端、把报废资产领用出去的情况状态机在后端兜住以后这类问题就很少出现了。2.3 领用、维修、盘点相关记录表业务流水表才是追溯的核心。领用记录表如下CREATE TABLE asset_usage ( id BIGINT AUTO_INCREMENT PRIMARY KEY, asset_id BIGINT NOT NULL, user_id VARCHAR(50) NOT NULL COMMENT 领用人, department_id BIGINT COMMENT 领用部门, apply_time DATETIME NOT NULL, approve_time DATETIME COMMENT 审批时间, approve_by VARCHAR(50) COMMENT 审批人, return_time DATETIME COMMENT 归还时间, status TINYINT NOT NULL COMMENT 0待审批 1已领用 2已归还 3已拒绝, remark VARCHAR(500) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;维修记录表类似增加了repair_cost费用字段和repair_result维修结果。盘点的核心是快照对账逻辑需要一张盘点计划表和一张盘点明细表。明细表里记录系统账面资产和实盘结果实盘结果有三个值相符、盘盈、盘亏。盘盈指扫码扫到了系统里不存在的资产编号盘亏指系统里有但清单上没有扫描到的资产。这些业务表在设计时要特别注意所有表都要记录创建时间和创建人方便追溯。资产相关的业务流水一旦生成就不能物理删除如果录错了只能新增一条反向冲销记录。这是财务系统的通用做法资产管理系统也适用。3. 核心功能实现与快速部署让代码先跑起来3.1 资产领用流程的完整实现资产领用是日常使用频率最高的功能流程走的是申请、审批、确认发放三步。我把整个流程放在一个Service类里用事务保证资产状态和领用记录要么一起成功要么一起失败。Transactional(rollbackFor Exception.class) public void approve(Long usageId, boolean approve) { AssetUsage usage assetUsageMapper.selectById(usageId); if (usage null || usage.getStatus() ! 0) { throw new BusinessException(记录不存在或已被处理); } AssetInfo asset assetInfoMapper.selectById(usage.getAssetId()); if (approve) { if (asset.getStatus() ! 0) { throw new BusinessException(资产不在库无法审批通过); } asset.setStatus(1); assetInfoMapper.updateById(asset); usage.setStatus(1); usage.setApproveTime(new Date()); } else { usage.setStatus(3); } assetUsageMapper.updateById(usage); }这个Service里最关键的判断是asset.getStatus() ! 0的校验。因为在申请提交后、管理员审批前的这段时间里这台资产有可能已经被另一个人领走了。如果不做状态校验直接通过审批就会造成一台资产同时被两个人领用账目立刻混乱。这就是典型的并发审批问题很多第一版系统都栽在这里。前端页面需要展示领用记录的状态我习惯用一个带颜色的tag来区分。提交申请时用户只能选择状态为在库的资产但前端过滤只是为了用户体验真正的校验必须落在后端。记住一条原则前端做的任何限制都是可绕过的后端才是最后一道防线。3.2 盘点快照与对账逻辑的实现资产盘点如果只是把系统里的资产列出来然后一一核对那盘点过程必须保证没有其他业务操作发生否则就会出现刚盘完的设备又被领走、明细对不上的情况。我的做法是在创建盘点计划时生成一份资产快照把当时的资产状态、所在部门、保管人全部复制到盘点明细表中。后续业务操作按正常流程进行等盘点结果回填时就以快照为准做差异比对。INSERT INTO stocktake_detail(plan_id, asset_id, asset_code, asset_name, status) SELECT #{planId}, id, asset_code, asset_name, status FROM asset_info WHERE deleted 0 AND status IN (0, 1)这张快照表的好处是即使盘点期间有人领用了某台资产盘点的账面数据也不会被悄悄更新最终盘点报告反映的是创建计划时刻的账实差异而不是盘到一半时被改动过的差异。这条经验是从一次盘点数据对不上的真实事故里总结出来的后面详细讲。盘点结果录入是另一个需要仔细设计的功能。如果通过扫码枪录入扫码后要立即根据条码反查资产快照判断该资产在不在本盘点计划内。在计划内且状态相符就标记为相符扫到计划外资产标记为盘盈。等盘点终止后快照里没有被扫到的资产自动标记为盘亏。这样整个对账过程几乎不需要人工干预资产专员只需要确认系统生成的盈亏报告即可。3.3 统计报表与Excel导出管理层最常看两个报表按部门汇总的资产数量和原值、按分类汇总的数量和原值。实现并不复杂就是两条带GROUP BY的SQL。SELECT d.name, COUNT(a.id) AS total_count, IFNULL(SUM(a.original_price), 0) AS total_price FROM asset_info a LEFT JOIN department d ON a.department_id d.id WHERE a.deleted 0 GROUP BY d.name有一个非常容易被忽略的问题LEFT JOIN部门后如果部分资产还没分配部门department_id为空这些资产会归到部门名为NULL的一组报表里看起来就多了一行空白部门。如果不想出现这种情况可以在SQL里用IFNULL(d.name, 未分配)处理或者在数据录入时强制资产必须归属部门。两种方案我最后选了后者因为资产进入系统时就应该明确归属否则之后盘点抄写部门名称容易抄错。资产折旧处理也放在报表模块。固定资产常用的是年限平均法月折旧额 原值 - 预计残值/ 预计使用月份数。我在资产卡片上增加了expected_life_months字段预计使用月数报表里用purchase_date和当前日期计算已计提月份残值率统一取5%。如果只想看简单口径也可以直接用ROUND(original_price * 0.95 / expected_life_months, 2)来算月折旧。Excel导出用POI注意一个性能上的坑一次性查几万行然后循环塞进HSSFWorkbook内存会飙得很高。改成分批5万行一组写入或者用SXSSFWorkbook流式写内存占用会下降很多。我们第一次导出8000条资产记录时后端服务直接OOM了换了流式写法之后再没出过问题。3.4 环境准备与快速启动步骤这部分写给刚上手的读者。整套系统的运行环境要求为JDK 1.8、Maven 3.6、MySQL 5.7、Node.js 14。前端建议用npm 6以上太老的版本依赖解析会出问题。第一步初始化数据库。把database/init.sql导入MySQL它会自动建库、建表并初始化管理员账号、默认角色、基础分类数据。导入命令很简单mysql -uroot -p init.sql第二步修改后端配置。在application.yml里改数据库连接信息核心就三个配置jdbc:mysql://localhost:3306/asset_db、用户名、密码。第三步启动后端。在项目根目录执行mvn spring-boot:run看到Started Application in xxx seconds就说明后端起来了默认端口8080。第四步启动前端。进入frontend目录执行npm install npm run serve浏览器访问localhost:8081用初始化脚本里的管理员账号登录。这里要注意前后端联调时的跨域问题我在后端配置了CORS允许本地开发端口访问如果你改了前端端口记得同步修改CORS配置。第五步如果有条件用Nginx托管前端打包产物。执行npm run build后会生成dist目录把这个目录作为Nginx的站点根目录后端接口通过反向代理转发到8080端口。内网环境一般不需要HTTPSHTTP明文就够了。配置例子如下server { listen 80; server_name asset.local; root /opt/asset-web/dist; location /api/ { proxy_pass http://127.0.0.1:8080/api/; } }4. 常见问题与排坑实录上线后踩过的真实事故4.1 资产编号并发重复这个问题的现场我记得很清楚。某天下午两个行政同事同时通过Excel批量导入资产结果出现了两条一样的资产编号。排查下来我的取号逻辑用了先查询当前最大编号拼出新编号再插入的方式数据库没有唯一索引时两个事务可以读到同一个最大值然后各自生成一样的编号。修复分两层第一层是数据库加唯一索引这是兜底哪怕程序逻辑再有漏洞也不可能产生重复编码第二层是优化取号逻辑改成基于编号流水表的原子自增。现在这段核心代码长这样public String generateCode(String categoryCode) { // 使用数据库行锁保证并发下每个分类的流水号只被一个线程占用 return assetCodeMapper.incrementAndGet(categoryCode, year); }incrementAndGet对应的SQL就是UPDATE asset_code_counter SET current_seq current_seq 1 WHERE category_code ? AND year ?然后立刻SELECT current_seq。更新时行锁会阻塞其他事务等前一个事务提交后后面的才能继续这样拿到的编号一定不重复。4.2 盘点期内业务操作导致明细对不上第一次做盘点上线的第二周行政部反馈一个问题某个部门有一台笔记本电脑盘点计划创建时显示在库但盘点结果是已领用还显示领用人在另外一个部门。当时我第一反应是状态更新出bug了查了半天才发现实际上是盘点计划创建之后、盘点录入之前这台电脑被另一个部门的人正常领用了台账上的状态自然就变了。盘点单据和实际业务操作是同时在运行的账面快照被覆盖了。换成快照模式之后这个问题彻底消失。现在系统中的逻辑是创建盘点计划时立即把资产状态复制到明细表盘点录入时只对比快照和实盘结果快照里有而实际没扫到就是盘亏实际扫到了但快照里没有就是盘盈。这样逻辑上无歧义账实差异一眼就能看明白。4.3 批量导入慢与数据校验资产台账支持Excel批量导入是行政部的刚需第一版导入我用的是循环单条insert600条数据导了2分钟被用户吐槽是不是在用2008年的电脑。后来改成MyBatis的batch批量插入600条数据压到2秒内。问题解决后我又想加一个校验逻辑导入前先检查编号是否在库里存在重复的直接标红不让导入。这个校验不能只靠Excel里查重因为Excel里可能看起来不重复但和库里已有数据一碰撞就重复了。正确做法是先读全量已存在的编号到内存Set再逐行比对。资产数量几千条时内存完全扛得住如果未来资产量到几十万可以用MySQL的INSERT ... ON DUPLICATE KEY UPDATE让数据库直接报错业务层再收集错误行号告诉用户。4.4 其他容易被忽略的坑除了上面三个主要问题还有几个细节经常让开发者栽跟头。第一个是Excel导入乱码。资产名称或者备注里含有中文用POI解析时如果读取的不是单元格字符串值就会出现乱码。解决方法是统一读取cell.getStringCellValue()前先判断cell.getCellType()遇到NUMERIC类型要转成字符串再处理不要直接用toString。第二个是逻辑删除后的唯一索引冲突。资产表有deleted字段做逻辑删除但asset_code唯一索引仍然生效。一旦某条资产被作废删除下次再导入相同编号的资产时就会违反唯一索引。解决方法是删除时同步修改编号加个后缀比如_DEL_20240101120000这样既保留历史痕迹又释放编号空间。第三个是操作日志必须记录关键字段的变更前后值。只记录某某修改了资产这个动作几乎没用出了问题还是要靠翻数据库binlog。我在资产表的更新操作里把变更前和变更后的status、department_id、custodian都记录到操作日志表这样任何一次状态变更都能追溯到人、时间和原因。第四个是权限控制别忘了后端接口也要过滤。很多管理系统只在前端路由做了判断后端接口完全没有拦截懂行的人直接拼接URL就能看所有数据。我用Spring拦截器在Controller入口统一做了权限校验不同角色只能访问自己的接口范围前端路由只是提供界面体验真正的安全边界在后端。我在写这套系统的过程中最大的体会是资产管理系统看起来是个小项目但实际80%的精力都没花在写页面上而是花在了状态流转、并发控制和数据一致性这些看不见的地方。如果让我重新做一个类似的项目我会先把状态机和数据快照这两件事设计清楚再开始写代码这会省掉后期无数个加班的夜晚。最后给准备拿这套源码做二次开发的朋友一个建议先去跑通领用审批和盘点对账这两个场景它们最能验证系统设计是否完善。把这两条链路跑顺了剩下的增删改查页面基本就是搬砖活了。