简介:基于RFID技术的固定资产管理系统Java源码,面向有资产管理数字化需求的企业及Java+MySQL开发者。系统覆盖资产入库、领用、借用、维修、报废、归还、报表等全流程,支持PC、手机、飞书三端接入,并实现RFID批量盘点,帮助企业提升固定资产管理效率。资源为zip压缩包,共772个文件,大小约4.06MB,其中317个java文件承载后端业务,180个html及配套js/css构成前端界面,38个xml与yml为配置,另有数据库sql脚本和启动脚本。已有3020人学习下载。开发者可获得可直接运行的完整项目,演示环境附测试账号,便于体验核心功能;代码分层清晰,可参考其RFID集成、多端接口及报表模块设计,适合用于企业资产系统二次开发或Java实战学习。
1. 资产管理系统源码到手后:先搞清它到底替你解决了什么
做 Java 开发的迟早会接到一个活:给公司或客户做资产管理系统。你以为是 CRUD,真正做起来发现资产状态流转、盘点差异、折旧计算、权限隔离全是坑。这套 Java 资产管理源码,核心就是把「资产从入库到报废」的完整生命周期和 RFID 盘点场景做成了一套可运行的后端工程,适合拿来直接改造成自己的业务底座。它解决的痛点很具体:资产编号怎么生成、领用归还是走状态机还是硬更新、RFID 盘点数据怎么批量入库不重复。适合两类人:一类是刚入职需要快速交付项目的初中级 Java 工程师,另一类是在选型阶段想找参考实现的架构师。先明确一点,这不是那种只有一个 README 的玩具工程,它把资产领域最常见的业务闭环都串起来了。
2. 技术选型与目录结构:为什么这套 Java 组合能扛住资产数据
2.1 Spring Boot + MyBatis Plus 的组合逻辑
资产管理系统本质上仍然是企业级 CRUD 应用,但资产数据有两个特点:字段多、状态变化频繁。一个资产从采购入库开始,要经历领用、归还、维修、调拨、报废,每个环节都要记录操作人和时间。用 Spring Boot 做容器管理、MyBatis Plus 做 ORM,是当前 Java 资产管理开源项目里最常见的技术组合,不是因为它新,而是因为它稳。
MyBatis Plus 的价值在于内置了逻辑删除、自动填充、分页插件,这些功能正好命中资产管理系统的需求。比如资产删除通常不是物理删除,而是逻辑删除,MyBatis Plus 的@TableLogic注解直接搞定,不用自己写UPDATE asset SET deleted = 1 WHERE id = ?。分页插件对资产列表这种高频查询也很有用,资产过万条以后,前端表格必须靠分页撑住。
2.2 工程目录怎么读:从 controller 到 mapper 的调用链
拿到源码先别急着跑,先把包结构看明白。这套工程用的是标准的分层架构:
com.company.asset ├── controller // 接收 HTTP 请求,做参数校验 ├── service // 业务逻辑层,资产状态流转在这里 ├── mapper // MyBatis Plus 的 Mapper 接口 ├── entity // 数据库实体类 ├── dto // 前端传入的参数对象 ├── vo // 返回给前端的视图对象 ├── config // 配置类,包括 MybatisPlusConfig、RedisConfig ├── common // 统一返回结果、异常处理、工具类 ├── rfid // RFID 盘点相关逻辑单独成包 └── task // 定时任务,比如资产折旧计算调用链是标准的Controller -> Service -> Mapper,但有两个地方值得注意。第一,rfid包是独立出来的,说明盘点逻辑和普通 CRUD 是解耦的,这个设计在二次开发时很省事。第二,task包里放着折旧计算定时任务,资产管理系统如果不做折旧,那和普通库存系统没区别,这块逻辑通常藏在 Service 层里,它单独拆包说明作者把资产领域的特有逻辑放在了一等位置。
2.3 关键配置项:数据源、Redis、文件上传路径
application.yml里有几组配置需要根据自己的环境改,否则启动必挂。数据源配置是第一个要动的,资产管理系统一般用 MySQL,注意时区参数,MySQL 8.0 必须加serverTimezone=Asia/Shanghai,否则查询时间字段会差 8 小时。
spring: datasource: url: jdbc:mysql://localhost:3306/asset_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 database: 0Redis 在这套系统里承担的角色是缓存资产编号递增器和登录态。资产编号不能用数据库自增主键替代,因为资产编号有业务含义,比如ZC-2024-0001,这个格式必须用 Redis 的 INCR 命令配合日期前缀生成。文件上传路径配置也要改,资产照片和采购合同附件会传到本地磁盘,默认配置是/data/asset/upload,Windows 环境下要改成D:/asset/upload,否则上传接口报FileNotFoundException。
3. 资产全生命周期核心代码:入库、领用、归还、维修、报废
3.1 资产表设计与状态机
资产管理系统的数据库设计核心是一张asset主表和一张asset_record流水表。主表存资产当前状态,流水表存每一次操作记录。这个设计是行业的常见做法,也是这套源码里最有复用价值的部分。很多人第一次做资产系统,只建一张表,字段改了直接 UPDATE,结果审计的时候什么都查不到。
CREATE TABLE `asset` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `asset_no` varchar(32) NOT NULL COMMENT '资产编号', `name` varchar(128) NOT NULL COMMENT '资产名称', `category_id` bigint(20) DEFAULT NULL COMMENT '分类ID', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1-在库 2-已领用 3-维修中 4-已报废', `user_id` bigint(20) DEFAULT NULL COMMENT '当前使用人', `purchase_price` decimal(10,2) DEFAULT NULL COMMENT '采购原价', `purchase_date` date DEFAULT NULL COMMENT '采购日期', `rfid_code` varchar(64) DEFAULT NULL COMMENT 'RFID标签编号', `deleted` tinyint(1) NOT NULL DEFAULT '0', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_asset_no` (`asset_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;状态字段status是整个系统的核心,所有业务操作都要围绕状态机来校验。常见的做法是:入库时状态为在库,领用时校验必须是「在库」才能变更为「已领用」,归还时校验必须是「已领用」才能回到「在库」。维修和报废同理。这套源码在 Service 层写了一个状态流转校验方法,每次更新状态前先查当前状态,不匹配直接抛业务异常。
3.2 资产入库与领用的 service 实现
资产入库的业务逻辑里有一个容易忽略的坑:入库操作要做幂等处理。前端可能会因为网络超时重复提交表单,导致同一批资产入库两次。源码里的处理方式是先验资产编号是否已存在,存在则直接返回错误。
@Override @Transactional(rollbackFor = Exception.class) public Long addAsset(AssetAddDTO dto) { // 1. 生成资产编号:ZC + 日期 + Redis序号 String assetNo = assetNoGenerator.generate("ZC"); // 2. 校验资产编号唯一性,防止并发重复 long count = this.lambdaQuery() .eq(Asset::getAssetNo, assetNo) .count(); if (count > 0) { throw new BusinessException("资产编号重复,请重试"); } // 3. 保存资产主表 Asset asset = new Asset(); BeanUtils.copyProperties(dto, asset); asset.setAssetNo(assetNo); asset.setStatus(AssetStatus.IN_STOCK.getCode()); this.save(asset); // 4. 写入资产操作流水 AssetRecord record = new AssetRecord(); record.setAssetId(asset.getId()); record.setAssetNo(assetNo); record.setOperateType(OperateType.IN_STOCK.getCode()); record.setOperateUserId(LoginUtil.getUserId()); assetRecordMapper.insert(record); return asset.getId(); }这段代码里关键是@Transactional注解和assetNoGenerator配合。事务保证资产主表和流水表要么同时成功要么同时失败,不会出现主表有数据但流水丢失的情况。资产编号生成器用的是 Redis INCR,但这里有一个并发隐患:步骤 2 的重复校验在极端并发下可能同时通过,因为count()查询和save()不是原子操作。真正稳妥的做法是依靠数据库的唯一索引兜底,所以建表语句里加了UNIQUE KEY uk_asset_no。
领用操作的逻辑正好反过来,先校验资产状态,再更新主表,最后插流水。
@Override @Transactional(rollbackFor = Exception.class) public void receiveAsset(Long assetId, Long userId) { Asset asset = this.getById(assetId); if (asset == null || asset.getDeleted() == 1) { throw new BusinessException("资产不存在"); } // 关键校验:只有“在库”状态才能被领用 if (!AssetStatus.IN_STOCK.getCode().equals(asset.getStatus())) { throw new BusinessException("当前资产状态不可领用"); } asset.setStatus(AssetStatus.RECEIVED.getCode()); asset.setUserId(userId); this.updateById(asset); // 记录流水 AssetRecord record = new AssetRecord(); record.setAssetId(assetId); record.setAssetNo(asset.getAssetNo()); record.setOperateType(OperateType.RECEIVE.getCode()); record.setOperateUserId(userId); assetRecordMapper.insert(record); }这里最值得借鉴的是状态校验前置。很多初写资产系统的人会漏掉这一步,直接updateById改状态,结果就会出现「一台已经报废的电脑被领用了」这种离谱数据。状态机的本质就一句话:不是你想改成什么状态就改,而是当前状态允许流转到什么状态才能改。
3.3 资产报废与折旧:逻辑删除背后那块遮羞布
资产报废是生命周期里最容易写错的功能。很多人的第一反应是删掉记录,但资产业务不允许物理删除,因为财务审计要留痕。源码里的报废操作是改变状态,同时把资产从在用列表里隐藏掉。
@Override @Transactional(rollbackFor = Exception.class) public void scrapAsset(Long assetId, String reason) { Asset asset = this.getById(assetId); if (asset == null) { throw new BusinessException("资产不存在"); } if (!AssetStatus.RECEIVED.getCode().equals(asset.getStatus()) && !AssetStatus.IN_STOCK.getCode().equals(asset.getStatus())) { throw new BusinessException("维修中资产不能报废"); } asset.setStatus(AssetStatus.SCRAPPED.getCode()); asset.setScrapReason(reason); asset.setScrapTime(new Date()); this.updateById(asset); }注意报废校验的两个允许状态:在库和已领用。维修中的资产不能报废,因为可能还有未完成的维修单关联着,这种约束就是状态机比自由更新强的地方。折旧这块用的是定时任务,每天晚上跑一次,按年限平均折旧法计算资产当前净值,结果存到asset_depreciation表,做资产盘点的时候可以直接取净值。
4. RFID 盘点实战:从扫码枪到数据库的完整链路
4.1 盘点模块的三种接入方式
RFID 是这套源码的亮点模块。资产管理系统做到中期,客户一定会提一个需求:能不能拿扫码枪走一圈就把资产盘了?这就是 RFID 盘点的使用场景。源码里抽象了RfidReaderService接口,支持三种接入方式,我在实际项目里也都遇到过。
public interface RfidReaderService { /** * 连接读写器 */ boolean connect(RfidReaderConfig config); /** * 读取标签,返回一批 RFID 标签码 */ List<String> readTags(); /** * 断开连接 */ void disconnect(); }第一种是有源读写器通过 TCP 直连服务器,读写器扫描到的标签实时上报,适合仓库门口这种固定点位。第二种是手持 PDA 离线采集,管理员拿着设备走一圈,采集完成后把标签列表导入系统。第三种是通过串口连接桌面式读写器,适合桌面级的批量读取。这套源码默认实现的是第二种,因为手持 PDA 是实际项目里最常用的形态,不需要布线,拿着就走。
4.2 手持机批量上报接口的幂等设计
盘点数据上报有一个隐藏问题:手持机可能在同一批数据里重复上报,也可能因为网络原因重传。如果直接循环插库,盘点记录表会重复。源码的处理方式是上报接口整体做幂等,用批次号batch_no做唯一约束。
@PostMapping("/rfid/report") public R<String> report(@RequestBody RfidReportDTO dto) { // dto.batchNo: 手持机生成的批次号,UUID // dto.tagList: RFID标签码列表 String batchNo = dto.getBatchNo(); // 批次号已存在则直接返回成功 Integer count = rfidBatchMapper.selectCount( new LambdaQueryWrapper<RfidBatch>() .eq(RfidBatch::getBatchNo, batchNo)); if (count != null && count > 0) { return R.ok("该批次已上报,跳过"); } rfidService.processBatch(dto); return R.ok("处理完成"); }这个设计的巧妙之处在于用批次号做天然幂等键,而不是查标签列表是否重复。因为一次盘点可能扫到几千个标签,逐个查重效率太低,用批次号挡住整批数据,效率高很多。RfidBatch表记录批次信息(盘点单ID、上报时间、标签数量),RfidTagDetail表记录该批次下的每个标签明细。
4.3 盘点差异报告:盘盈盘亏怎么生成
盘点的最终输出是一份差异报告。RFID 扫到的标签要和数据库里的资产做匹配,匹配不上的就是盘亏(账上有实物没有),数据库里有但没扫到的是盘亏(账上有实物找不到)。源码里在RfidService中实现了一个比对方法。
public RfidDiffReport compareDiff(Long inventoryPlanId, List<String> scannedTags) { // 1. 查资产表里所有 rfid_code 不为空的在用资产 List<Asset> assets = assetMapper.selectList( new LambdaQueryWrapper<Asset>() .isNotNull(Asset::getRfidCode) .ne(Asset::getStatus, AssetStatus.SCRAPPED.getCode())); // 2. 把扫描到的标签转成 Set 方便比对 Set<String> scannedSet = new HashSet<>(scannedTags); // 3. 遍历资产,找出未扫到的 List<String> missingTags = new ArrayList<>(); for (Asset asset : assets) { if (!scannedSet.contains(asset.getRfidCode())) { missingTags.add(asset.getRfidCode()); } } // 4. 扫描到的标签里,数据库没有的算盘盈 Set<String> dbTags = assets.stream() .map(Asset::getRfidCode) .collect(Collectors.toSet()); List<String> extraTags = scannedTags.stream() .filter(tag -> !dbTags.contains(tag)) .collect(Collectors.toList()); // 5. 组装差异报告 RfidDiffReport report = new RfidDiffReport(); report.setMissingTags(missingTags); // 盘亏 report.setExtraTags(extraTags); // 盘盈 return report; }这段比对逻辑有一个注意点:过滤条件里加了ne(Asset::getStatus, AssetStatus.SCRAPPED.getCode()),报废资产不参与盘点比对。如果不加这个过滤,报废但没撕掉 RFID 标签的资产会被算成盘亏,每次盘点都报差异,这就是常见的数据噪声。实际用的时候还要注意标签码的大小写,不同厂商的读写器返回的标签格式可能不同,建议统一转大写再比对。
5. 部署与避坑清单:从本地跑通到上线这五天最容易翻车的地方
5.1 本地环境快速启动:Maven 与 MySQL 参数
先把环境跑起来再研究代码,这是最快的学习路径。项目用 Maven 管理依赖,JDK 版本要求 1.8 以上。启动前先建数据库,源码目录下通常有sql/init.sql,如果没找到,直接用前面给出的建表语句也能跑。
# 1. 初始化数据库 mysql -uroot -p < sql/init.sql # 2. 修改 application.yml 里的数据库密码和 Redis 地址 # 3. 启动 Redis,Windows 下直接双击 redis-server.exe # 4. 编译并启动 mvn clean package -DskipTests java -jar target/asset-management-system.jar启动成功后访问http://localhost:8080/api/health看健康检查接口。如果端口被占用,在application.yml里改server.port。后端默认端口是 8080,前端开发环境下 Vite 代理会转发到 8080,改成其他端口记得同步改前端代理配置。
5.2 避坑清单:6 条真实踩坑记录
坑 1:MySQL 8.0 驱动导致启动报 Public Key Retrieval 错误
现象:项目启动时数据源初始化失败,报错Public Key Retrieval is not allowed。
原因:MySQL 8.0 默认使用 caching_sha2_password 认证,JDBC 连接时默认不允许客户端从服务器获取公钥。
解决:连接串加参数allowPublicKeyRetrieval=true&useSSL=false,或者把连接串改成jdbc:mysql://localhost:3306/asset_db?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true。这两个参数是 MySQL 8.0 环境下的标配,不加必出错。
坑 2:Redis 未启动导致资产编号接口报错
现象:调用新增资产接口报JedisConnectionException: Could not get a resource from the pool。
原因:资产编号生成强依赖 Redis INCR,Redis 没启动或者 IP 配置不对。
解决:本地开发先启动 Redis,检查spring.redis.host和port。另外注意 Redis 密码,如果有密码没配,连接也会失败。
坑 3:逻辑删除字段导致自定义 SQL 查询结果错乱
现象:写了一个自定义 SQL 联查资产的领用记录,查出了已删除的数据。
原因:MyBatis Plus 的@TableLogic只在它的内置方法里生效,自己手写的 SQL 如果没加deleted = 0条件,逻辑删除就成了摆设。
解决:自定义 SQL 里手动拼接AND a.deleted = 0。血泪经验:凡是用了逻辑删除的表,所有自定义 SQL 都要检查一遍有没有过滤 deleted 字段。
坑 4:状态机校验漏了一个入口,导致资产从维修直接变成报废被拦截
现象:测试人员把维修中的资产点报废,接口返回成功,但列表里资产消失了。
原因:报废方法的校验逻辑没有加拦截器或者 AOP 统一处理,只校验了「非报废状态」,没校验「维修中不可报废」。
解决:把状态流转校验抽出来做成一个StateMachineValidator,在 Service 层所有变更状态的方法入口统一调用。这个做法推荐直接抄进自己的项目。
坑 5:批量导入 Excel 时内存溢出
现象:导入一万条资产记录的 Excel,后端 OOM。
原因:EasyExcel 默认一次性把整个 Sheet 读进内存,一万条可能不到 OOM 的量级,但资产表字段多,一条记录十几个字段,对象数量上去以后内存吃紧。
解决:改用 EasyExcel 的监听器模式逐行读取,每读 1000 条就手动清一次List。这套源码里已经实现了AssetImportListener,注意看它的invoke方法里有没有做内存释放。
坑 6:RFID 标签码在数据库里带空格,导致盘点匹配不上
现象:手持机扫到的标签和数据库里的标签一致,但比对结果全部显示盘亏。
原因:导入标签时没有 trim,数据库里存了空格,前端展示看不出来,比对时Set.contains()因为空格差异返回 false。
解决:入库时统一做tag.trim(),比对前也做一次 trim 和 toUpperCase。这类问题排查起来非常玄学,因为肉眼看不出来,得靠打印日志比对长度才能发现。
6. 把这套源码用得更顺手:Excel 批量导入、缓存刷新与消息通知
资产管理系统上线以后,最常用的操作反而不是单个录入,而是 Excel 批量导入。运维部门手里本来就有一张老系统的资产台账,几千条数据需要一次性迁过来。源码里用了 EasyExcel 的监听器模式做导入,但有几个细节做得不够,我会在二次开发时改掉。
第一个是导入模板校验。默认实现只校验了几个必填字段,资产分类这类外键关联字段没有提前做合法性校验,导致导入结束后日志里一堆外键错误。我的习惯是在AssetImportListener的invoke方法里先把分类名称转换成分类 ID,转换失败的记录直接进错误列表,而不是整体回滚。
@Override public void invoke(AssetExcelData data, AnalysisContext context) { // 同一批次共用一个缓存,避免每行都查一次数据库 Map<String, Long> categoryCache = new HashMap<>(); Long categoryId = categoryCache.computeIfAbsent( data.getCategoryName(), name -> categoryService.getIdByName(name) ); if (categoryId == null) { errors.add("第" + context.getCurrentRowIndex() + "行分类不存在: " + data.getCategoryName()); return; } Asset asset = new Asset(); BeanUtils.copyProperties(data, asset); asset.setCategoryId(categoryId); asset.setStatus(AssetStatus.IN_STOCK.getCode()); assets.add(asset); // 每 1000 条批量插入一次,防止 List 无限增长 if (assets.size() >= 1000) { saveBatch(); } }用computeIfAbsent做缓存是个省事技巧,分类名称到 ID 的映射只需要在第一次出现时查库,后面全部走内存。导入数据量大时,这个方法能把耗时缩短一半以上。
第二个要改的是资产编号生成的并发场景。Redis INCR 本身是原子的,但如果 Redis 重启,递增序列会从 1 开始,如果数据库已有ZC-2024-0001,新生成的编号就是ZC-2024-0001,唯一索引直接炸掉。我遇到过一次,修复方式是在生成器里先查数据库最大值,再对比 Redis 当前值,取较大者作为起始值。
从那以后,我每次做资产系统都要强制走一遍边界检查:编号生成会不会撞唯一索引、状态流转有没有漏拦截、逻辑删除有没有污染自定义 SQL。这套源码本身不算完美,但它的价值在于把资产管理系统最核心的坑都踩过一遍并且给出了解决方案,你拿到手改一改就能用于生产。希望帮到你。
本文还有配套的精品资源,点击获取