做过企业信息化项目的朋友应该都有共鸣:资产管理系统看起来人畜无害,不就是把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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;几个容易被忽略的点一定要说一下。
第一,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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;维修记录表类似,增加了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端口。内网环境一般不需要HTTPS,HTTP明文就够了。配置例子如下:
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批量导入是行政部的刚需,第一版导入我用的是循环单条insert,600条数据导了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%的精力都没花在写页面上,而是花在了状态流转、并发控制和数据一致性这些看不见的地方。如果让我重新做一个类似的项目,我会先把状态机和数据快照这两件事设计清楚,再开始写代码,这会省掉后期无数个加班的夜晚。
最后给准备拿这套源码做二次开发的朋友一个建议:先去跑通"领用审批"和"盘点对账"这两个场景,它们最能验证系统设计是否完善。把这两条链路跑顺了,剩下的增删改查页面,基本就是搬砖活了。