ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用Spring Boot打造个人物品管理系统:从零到部署的完整实践

用Spring Boot打造个人物品管理系统:从零到部署的完整实践

家里东西多到一定程度,最怕的就是“明明记得有,却死活找不到”。我自己的工具箱、电子配件、摄影器材越攒越多,找一颗螺丝或者一根数据线经常要翻好几个抽屉。后来动手用 Spring Boot 做了这套个人物品管理系统,把“找东西”变成了“查数据库”,几秒钟就能定位物品在哪个柜子、哪个格子。这篇文章就是把我从零搭建这套系统时踩过的坑、验证过的方案完整写出来,包括表结构怎么设计、借还登记怎么做、照片怎么存、统计报表怎么出、部署时哪些细节容易翻车。如果你也有一堆需要归档的物件,或者正在找一个练手的 Spring Boot 实战项目,这篇文章可以直接照着做。

1. 为什么是“物品管理”而不是“库存管理”:需求边界与技术选型

1.1 个人物品管理的真实痛点

很多人在明确需求阶段就栽了跟头。一上来就参照企业的 ERP 库存系统,设计了供应商、采购单、出入库单、盘点单、审批流、多角色权限……做完发现根本用不起来。个人物品管理的本质和企业库存管理完全是两回事。

个人/家庭场景下,物品数量一般就是几百到几千件,没有复杂的批次、序列号、保税逻辑。真正的痛点只有三个:一是东西放哪儿了记不住;二是借出去的东西回不来、想不起来借给谁了;三是同类东西重复买,钱白花。

所以需求边界要划清楚:物品档案的增删改查、分类归档、位置标记、借还记录、基础统计、照片留存。权限系统只需要“单人使用”级别,顶多分个管理员和只读访客,不需要工作流和审批。一上来就拒绝堆砌重逻辑,反而能让系统快速用起来。

1.2 Spring Boot + MySQL + MyBatis-Plus 的组合逻辑

技术选型我直接定了 Spring Boot 2.7 + MySQL 8.0 + MyBatis-Plus 3.5,这套组合的精气神就是“单表 CRUD 极快、复杂查询可控、资料遍地都是”。

为什么要 Spring Boot 而不是 Spring Cloud?因为这套系统只有一个应用、一个数据库,没必要引入注册中心、配置中心、网关那一大套。微服务是组织复杂度高到一定程度才需要的解药,不是项目数量的装饰品。

为什么要 MyBatis-Plus 而不是 JPA 或者 MyBatis 原生?JPA 在动态查询上写起来啰嗦,原生 MyBatis 单表 CRUD 要手写一堆 XML,而 MyBatis-Plus 的 BaseMapper 直接提供了 insert、updateById、selectPage、deleteById,配合 QueryWrapper 就能拼出多条件的动态查询,单表的活儿几乎不用写 SQL。这套系统 80% 的查询都是单表查询,剩下 20% 用注解 SQL 或者 XML 都能轻松解决。

前端我是前后端分离,用的 Vue3 + Element Plus。如果你不想拆前端工程,直接用 Spring Boot 内置的 Thymeleaf 加一点点 jQuery 也能跑,后面会把两套方案的区别讲清楚。

1.3 功能边界:先做什么,后做什么

我把功能拆成一个“MVP 核心闭环”和两个“后续扩展”:

MVP 核心闭环:

  • 物品档案:登记、编辑、作废、删除
  • 分类管理:支持多级分类,比如“数码产品 / 充电器 / USB-C”
  • 位置管理:记录物品存放的具体位置,精确到柜子编号和格子编号
  • 借还登记:记录谁借走了、计划什么时候还、实际归还时间
  • 基础统计:分类数量、位置分布、借出清单、资产总价值

后续扩展我留了两个口子,但第一版不做的:

  • 照片存储:第一版先支持单张主图,因为多图会引入相册表、封面逻辑,复杂度立刻上来
  • Excel 导入导出:批量录入靠它,但第一版手工录入先把数据流跑通

我在设计时一直提醒自己:不要想着一口气吃成胖子,先让“录进去、能找到、能追踪”这三件事跑顺,后面才有资格谈优化。

2. 数据模型设计:把“东西放哪”变成可查询的字段

2.1 五张核心表的结构与 DDL

数据模型是整个系统的地基。我设计了五张表:物品表、分类表、位置表、借还记录表、图片信息表。直接看 DDL 更直观:

CREATE TABLE category ( id BIGINT PRIMARY KEY AUTO_INCREMENT, parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父分类ID,0表示根', name VARCHAR(100) NOT NULL COMMENT '分类名称', sort_order INT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE location ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(200) NOT NULL COMMENT '位置名称,如:书房-A柜-3层-左格', description VARCHAR(500) DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, item_code VARCHAR(64) NOT NULL UNIQUE COMMENT '物品编号', name VARCHAR(200) NOT NULL COMMENT '物品名称', category_id BIGINT NOT NULL COMMENT '所属分类', location_id BIGINT NOT NULL COMMENT '存放位置', status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1在库 2借出 3已报废', quantity INT NOT NULL DEFAULT 1 COMMENT '数量', purchase_price DECIMAL(10,2) DEFAULT NULL COMMENT '购入单价', purchase_date DATE DEFAULT NULL COMMENT '购入日期', guardian VARCHAR(100) DEFAULT NULL COMMENT '保管人', remark VARCHAR(500) DEFAULT NULL COMMENT '备注', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_category (category_id), INDEX idx_location (location_id), INDEX idx_status (status), INDEX idx_name (name) ); CREATE TABLE borrow_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, item_id BIGINT NOT NULL, borrower VARCHAR(100) NOT NULL COMMENT '借用人', borrow_date DATETIME NOT NULL, planned_return_date DATE DEFAULT NULL COMMENT '计划归还日期', actual_return_date DATETIME DEFAULT NULL COMMENT '实际归还时间', remark VARCHAR(500) DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_item (item_id), INDEX idx_borrower (borrower) ); CREATE TABLE item_image ( id BIGINT PRIMARY KEY AUTO_INCREMENT, item_id BIGINT NOT NULL, file_path VARCHAR(300) NOT NULL COMMENT '文件路径', file_size BIGINT DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_item (item_id) );

五张表之间的关联很简单:item 用 category_id 和 location_id 分别关联分类和位置,borrow_record 用 item_id 关联物品,item_image 用 item_id 回连物品。没有搞复杂的中间表,因为这套系统不涉及“一个物品属于多个分类”或者“一个物品同时分属多个位置”的复杂模型。

2.2 分类树与位置编码:编码比层级更省事

分类表用 parent_id 做树形结构,这个没什么争议。查询“某个分类下所有子分类的物品”时,需要递归或预排序。数据量不大时,我选择在 Java 服务层先查全部分类构建树,再收集所有子分类 ID,一次 IN 查询搞定。几百个分类的内存构建完全无压力。

位置表则是关键设计点。没有做省市区那样的三级联动,而是直接用一段自然描述字符串,比如“书房-A柜-3层-左格”。有人会问,这样不是不好做统计吗?我的回答是:个人物品管理场景下,位置信息本质是一个“可读地址”,不是结构化的行政区划。你要做的是“搜索时能按关键字过滤”,而不是“按楼层维度聚合报表”。字符串加模糊查询,简单直接,效果完全够用。

关于 location 要不要冗余展开成 province/city/district 那样的结构,我的建议是不要。位置表永远只有一层,存一段“人类能读懂的话”,配合关键字 like 查询。这样录入的人不需要先建好树形位置才能录物品,维护成本瞬间低很多。

2.3 状态机设计:在库/借出/报废的流转约束

物品状态我用了三个值:

  • 1 在库
  • 2 借出
  • 3 已报废

状态流转规则在 Service 层强制控制:在库才能发起借出,借出状态归还后回到在库,在库才能作废。一上来就设计复杂的可逆留痕(比如“已借出”再退回“在库草稿”)没必要,反而让用户搞不清物品到底还能不能操作。

一个容易被忽略的细节是:物品数量大于 1 的时候,比如你买了 20 个螺丝,借出去 5 个,状态怎么算?第一版我简化成“只要有借出就整体置为借出”,但实际上这会让 15 个在库的螺丝也显示为不可借出。后来我把 state 扩展成了“库存可用数量 available_quantity”,每次借出扣减,每次归还回补,物品状态根据 available_quantity 动态计算:大于 0 表示在库,等于 0 且存在借出记录表示全部借出,等于 0 且无借出记录表示空库存。

如果按简化版做,“数量”字段就只是档案信息,不参与借还逻辑,这样也可以接受。但如果你追求严谨,建议一开始就把 available_quantity 设计进去,多加一个字段不算什么成本,后面能省掉返工。我实际做的时候改过这一版,体会很深。

3. 核心功能实现:登记、检索与借还的代码落地

3.1 物品登记接口:参数校验与唯一编号生成

登记接口是这套系统最常用的入口,代码逻辑不复杂,但有两个细节很关键:参数校验和编号生成。

先看参数校验,我全部用 JSR 303 注解完成,不写一堆手动的 if else:

@Data public class ItemCreateRequest { @NotBlank(message = "物品名称不能为空") @Size(max = 200, message = "物品名称长度不能超过200") private String name; @NotNull(message = "分类不能为空") private Long categoryId; @NotNull(message = "位置不能为空") private Long locationId; @Min(value = 1, message = "数量至少为1") private Integer quantity; @DecimalMin(value = "0.0", inclusive = true, message = "单价不能为负数") private BigDecimal purchasePrice; @JsonFormat(pattern = "yyyy-MM-dd") private LocalDate purchaseDate; private String guardian; private String remark; }

物品编号我的规则是:ITEM+ 年月日时分秒 + 三位随机数。比如ITEM20250620153000123。为什么不直接用数据库自增 ID?因为用户报修、对账、贴标签的时候,自增 ID 太短容易重,而且对外暴露自增 ID 在安全性上也不好看。生成逻辑在 Service 层实现,再加一个数据库唯一索引兜底,万一随机碰撞了直接重新生成。

Service 层的核心逻辑:

@Transactional public Long createItem(ItemCreateRequest request) { Category category = categoryMapper.selectById(request.getCategoryId()); if (category == null) { throw new BizException("所选分类不存在"); } Location location = locationMapper.selectById(request.getLocationId()); if (location == null) { throw new BizException("所选位置不存在"); } Item item = new Item(); BeanUtils.copyProperties(request, item); item.setItemCode(generateItemCode()); item.setStatus(ItemStatus.IN_STOCK.getCode()); item.setAvailableQuantity(request.getQuantity()); itemMapper.insert(item); return item.getId(); }

注意这个方法加了@Transactional。虽然目前只插入了一张表,但后续一旦加上“插入成功后记录操作日志”或者“同步生成图片关联”的逻辑,事务边界就会越扩越大。从一开始就养成事务思维没坏处。

3.2 检索功能:模糊查询方案细节

检索是“找东西”的关键路径。用户的使用习惯通常是:在搜索框输入几个关键词,比如“USB 线”,然后系统在名称、备注、位置描述里一起搜。

MyBatis-Plus 的 QueryWrapper 写得非常舒服:

public PageResult<ItemVO> searchItems(ItemQueryRequest query) { Page<Item> page = new Page<>(query.getPageNum(), query.getPageSize()); LambdaQueryWrapper<Item> wrapper = Wrappers.lambdaQuery(); if (StringUtils.hasText(query.getKeyword())) { String keyword = query.getKeyword().trim(); wrapper.and(w -> w.like(Item::getName, keyword) .or().like(Item::getRemark, keyword) .or().like(Item::getItemCode, keyword)); } if (query.getCategoryId() != null) { wrapper.eq(Item::getCategoryId, query.getCategoryId()); } if (query.getLocationId() != null) { wrapper.eq(Item::getLocationId, query.getLocationId()); } if (query.getStatus() != null) { wrapper.eq(Item::getStatus, query.getStatus()); } wrapper.orderByDesc(Item::getUpdateTime); IPage<Item> result = itemMapper.selectPage(page, wrapper); // 组装分类名、位置名的视图对象 List<ItemVO> voList = result.getRecords().stream() .map(item -> { ItemVO vo = new ItemVO(); BeanUtils.copyProperties(item, vo); vo.setCategoryName(categoryMapper.selectById(item.getCategoryId()).getName()); vo.setLocationName(locationMapper.selectById(item.getLocationId()).getName()); return vo; }) .collect(Collectors.toList()); return new PageResult<>(voList, result.getTotal()); }

这个实现有一个明显的性能隐患:分页查询后,在循环里一次一次 selectById 查分类名和位置名,N 条数据就是 N 次查询。数据量小的时候没问题,但每页 20 条就是 40 次额外 SQL,体验会随着数据量的增加迅速变差。

实际项目里我改成了批量回填:先用selectBatchIds一次性查出本页涉及的全部分类和位置,内存中组装成 Map,再循环拼接。改动并不复杂,但效果非常明显。这一段经验就是“先能让东西跑起来,再回来调性能”的典型案例。

3.3 借还登记:避免“东西找不到了”的关键逻辑

借还登记是整个系统里最容易出逻辑漏洞的地方。很多人一开始只在 borrow_record 里插一条记录,然后不管了。结果就是东西借出去三个月,状态还是“在库”,因为没人写那条“归还”逻辑。

我的实现思路是:借出和归还是两个接口,但共享同一个状态变更核心逻辑,全部走事务。

@Transactional public void borrowItem(Long itemId, String borrower, LocalDate plannedReturnDate) { Item item = itemMapper.selectById(itemId); if (item == null) { throw new BizException("物品不存在"); } if (item.getAvailableQuantity() == null || item.getAvailableQuantity() <= 0) { throw new BizException("物品当前无可借数量"); } BorrowRecord record = new BorrowRecord(); record.setItemId(itemId); record.setBorrower(borrower); record.setBorrowDate(LocalDateTime.now()); record.setPlannedReturnDate(plannedReturnDate); borrowRecordMapper.insert(record); item.setAvailableQuantity(item.getAvailableQuantity() - 1); itemMapper.updateById(item); }

这里有一个非常关键的动作:插入借还记录和扣减可用数量,必须在同一个事务里。否则一旦插入成功而扣减失败,就会出现“记录显示借出去了,库存数量却没变”的数据不一致问题。

归还逻辑相对就简单了:找到该物品最近一条未归还的记录,更新 actual_return_date,然后把 available_quantity 加回去。因为个人场景下借还不会太频繁,不需要像图书馆系统那样处理复杂的预约和逾期,但“未归还记录数 + 可用数量 = 当前实物数量”这个盘点的守恒公式始终要成立。

为了防呆,借出时我会校验plannedReturnDate不能早于今天,归还时校验该物品确实存在未归还记录。这些细小的校验在实际使用里非常管用,能避免大部分因为点错按钮造成的数据问题。

3.4 照片管理:路径存储而不是二进制入库

物品照片最开始有人建议直接把图片转 base64 塞进数据库字段。这个方案在小规模时可以跑,但图片一多,数据库表体积迅速膨胀,备份恢复都会变慢。正确做法是:图片文件存磁盘,数据库只存文件路径。

我在 Spring Boot 里配置一个上传目录,然后用 WebMvc 做静态资源映射:

app: upload-path: /data/item-files/
@Configuration public class UploadFileConfig implements WebMvcConfigurer { @Value("${app.upload-path}") private String uploadPath; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/files/**") .addResourceHandler("/files/**") .addResourceHandlers(ResourceHandlerRegistry .resourceHandler("/files/**") .addResourceLocations("file:" + uploadPath)); } }

上传文件的处理逻辑需要注意几点:文件名不要用原始文件名,因为原始文件名可能包含中文和特殊字符,也容易碰撞。我统一用 UUID 重新命名,扩展名从原始文件里取出来并做白名单校验,只允许 jpg、png、webp、gif。

public String storeImage(MultipartFile file) { String originalFilename = file.getOriginalFilename(); String ext = StringUtils.getFilenameExtension(originalFilename); if (!Arrays.asList("jpg", "jpeg", "png", "webp", "gif").contains(ext.toLowerCase())) { throw new BizException("不支持的图片格式"); } String newFilename = UUID.randomUUID().toString().replace("-", "") + "." + ext; File target = new File(uploadPath, newFilename); try { file.transferTo(target); } catch (IOException e) { throw new BizException("图片保存失败"); } return "/files/" + newFilename; }

图片表 item_image 记录 item_id 和文件路径,删除物品时先删文件再删记录,顺序不能反。因为先删了记录万一文件删不掉,就成了没人引用、白白占磁盘空间的孤儿文件;先删文件则至少还能靠记录知道有个文件当时存在过。

4. 统计与导出:让散落的物品数据产生价值

4.1 分类/位置维度的聚合统计实现

物品数据积累到一两百条之后,单纯的列表已经不够用了,你得能回答两个问题:我钱都花在哪个分类上了?东西都堆在哪些位置?

分类维度的统计用一条 SQL 就能跑出来:

SELECT c.id, c.name, COUNT(i.id) AS item_count, COALESCE(SUM(i.purchase_price * i.quantity), 0) AS total_value FROM category c LEFT JOIN item i ON i.category_id = c.id AND i.status != 3 GROUP BY c.id, c.name ORDER BY total_value DESC;

位置维度的统计类似,换成 group by location_id 就行。这里我故意排除掉已报废状态的物品,因为作废物品不应参与资产统计,否则资产永远虚高。

前端展示的时候我做了两个小组件:一个是分类的条形图,一个是位置的列表。条形图用 ECharts,前端从后端拿聚合数据,直接渲染。个人场景不需要多么玄乎的可视化,一个条形图加一个简单的饼图足够回答上面那两个问题了。

4.2 资产总价值与 Excel 导出方案

首页要看一个总数字:当前在库物品总价值。这个数我用一个 SQL 拿下:

public BigDecimal calculateTotalAssetValue() { QueryWrapper<Item> wrapper = new QueryWrapper<>(); wrapper.eq("status", 1); wrapper.select("COALESCE(SUM(purchase_price * quantity), 0) AS total_value"); Map<String, Object> row = itemMapper.selectMaps(wrapper).get(0); return new BigDecimal(row.get("total_value").toString()); }

注意这里我特意简化成了“只统计在库状态”,因为借出的物品价值归属太复杂,严格算应该看“目前所有的资产价值”而不是“手里的价值”。个人使用中,我只看手里的,借出去的不算在自己资产里。

Excel 导出用 EasyExcel 实现。和 POI 原生比,EasyExcel 在内存占用上优势明显,而且注解式模型简单易懂:

@Data public class ItemExportRow { @ExcelProperty("物品编号") private String itemCode; @ExcelProperty("物品名称") private String name; @ExcelProperty("分类") private String categoryName; @ExcelProperty("位置") private String locationName; @ExcelProperty("状态") private String statusDesc; @ExcelProperty("数量") private Integer quantity; @ExcelProperty("单价") private BigDecimal purchasePrice; @ExcelProperty("购入日期") @JsonFormat(pattern = "yyyy-MM-dd") private LocalDate purchaseDate; }

导出接口先查全量数据(个人场景一般不到几千条,直接查内存没问题),然后EasyExcel.write(outputStream, ItemExportRow.class).sheet("物品清单").doWrite(rows)写出去。如果未来数据量到几万条,可以改成“分批查询 + 分批写”,但至少前期完全够用。

4.3 简单报表:家庭/个人场景够用即可

我见过有人在个人物品管理系统里强行上一套报表引擎,支持拖拽、自定义维度、多级钻取,结果几个月没人打开过。个人场景下,真正经常看的报表就三类:

  • 分类汇总:哪个分类东西最多、最值钱
  • 借出清单:谁借走了什么、借了多久没还
  • 最近新增:最近 30 天录入了什么,方便发现自己最近又买了什么

为了做借出清单,我建了一个视图,把 borrow_record 和 item、borrower 关联起来,筛选条件是 actual_return_date IS NULL。这个视图的输出直接就是一个“未还物品表格”,管理自己的外借物品非常好用。

SELECT i.item_code, i.name AS item_name, br.borrower, br.borrow_date, br.planned_return_date, DATEDIFF(CURDATE(), br.planned_return_date) AS overdue_days, l.name AS location_name FROM borrow_record br JOIN item i ON br.item_id = i.id LEFT JOIN location l ON i.location_id = l.id WHERE br.actual_return_date IS NULL ORDER BY br.planned_return_date ASC;

逾期天数直接用 DATEDIFF 算出来,绿色表示未逾期,黄色表示 7 天内到期,红色表示逾期未还。这样一屏就能看清楚:什么东西快到期该催了。

5. 部署运行与踩坑记录:从开发机到稳定使用的细节

5.1 环境准备与项目骨架搭建

我建议的环境版本是 JDK 8 或 11,Spring Boot 2.7.x,MySQL 8.0,Maven 3.8+。如果非要追新的 JDK 17 加 Spring Boot 3.x 也不是不行,但很多老教程和网上现成排错经验都是针对 2.7 的,对新手上手没那么友好。

创建项目我推荐直接去 Spring Initializr 勾选 Web、MySQL Driver、Validation,然后手动引入 MyBatis-Plus 和 EasyExcel 的依赖,起步更干净,不用带一堆没用的 starter。

打包部署就更简单了:

mvn clean package -DskipTests java -jar target/item-manage-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod

生产配置单独放在 application-prod.yml 里,数据库账号密码不要写在代码里,用环境变量注入。这是很多新手最容易忽略的安全问题:把数据库密码提交到代码仓库。哪怕只是个人项目,也应该养成把敏感信息放环境变量的习惯。

5.2 高频踩坑:日期、时区、序列化问题

这几个坑我基本每个项目都要踩一遍,写出来你直接避开。

第一个坑是 MySQL 时区问题。连接串不指定 serverTimezone,Spring Boot 2.7 跑起来连数据库时经常会报The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized。解决办法是在 JDBC URL 里明确加serverTimezone=Asia/Shanghai&useSSL=false&characterEncoding=UTF-8。

第二个坑是 LocalDateTime 序列化格式。Jackson 默认把 LocalDateTime 序列化成数组形式,前端拿到的是[2025, 6, 20, 15, 30, 0],很丑也很难解析。全局统一配置:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai

同时需要引入jackson-datatype-jsr310,并在 LocalDateTime 字段上标注@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss"),这样才能保证返回值可读。

第三个坑是 MyBatis-Plus 的驼峰映射。如果数据库字段用下划线命名(比如 item_code),实体用驼峰命名(itemCode),MyBatis-Plus 默认开启驼峰映射,这一般没问题。但如果你在 XML 里手写 SQL 返回 Map,Map 的 key 是下划线名,前端拿不到驼峰字段,需要手动AS itemCode或者在配置里开启映射。这个坑很隐蔽,查了半天才发现是 Map key 的问题。

第四个坑是删除文件失败。Windows 开发机上,文件被占用时File.delete()返回 false,代码却没抛异常,导致记录删了文件还在。我的规避方式是:先尝试删除,如果失败就记录一条日志,不阻塞主流程。毕竟这是一套个人管理系统,及时响应比严格一致更重要,但日志必须留痕,方便事后清理。

5.3 备份策略:小数据量也要防灾难

个人物品管理系统最怕的不是崩溃,而是数据丢失。你辛辛苦苦录入几千条物品档案,有一天磁盘坏了,或者误删了表,前面所有努力全白费。所以备份策略必须从一开始就立起来。

我的做法是一个简单的 Shell 脚本,每天凌晨用 mysqldump 全量备份数据库,保留最近 7 天的备份文件,再同步一份到网盘或移动硬盘:

#!/bin/bash BACKUP_DIR="/data/backup/mysql" DATE=$(date +%Y%m%d%H%M) mysqldump -u用户名 -p密码 item_db > "$BACKUP_DIR/item_db_$DATE.sql" find "$BACKUP_DIR" -type f -name "*.sql" -mtime +7 -delete

配合 crontab 每天凌晨执行一次。个人项目不搞主从复制、不搞增量备份,全量备份一周内滚动保留足够应对绝大多数意外。

另外一个容易被忽略的备份对象是上传的图片文件。数据库备份只是把路径备份了,文件本体如果丢了照样傻眼。我的方案是把上传目录整体纳入备份脚本,数据库和文件一起打包,恢复时两边同时还原。

5.4 一个真实的“查不到物品”排查思路

有一次我明明知道某个充电器登记过,但就是在系统里搜不到。排查链路如下:先看数据库原生查询,发现物品确实存在,但状态被标记成“已报废”。再看列表页默认过滤条件,发现前端把状态选择器默认值设成了“在库”,导致报废物品在列表里不可见。最后在搜索接口里发现,关键字搜索没有检查状态条件,但列表查询默认加了 status=1 的过滤,两个查询逻辑不一致,给用户造成了“数据消失”的错觉。

修复方式很简单:统一列表查询的默认状态过滤逻辑,并明确告诉用户“状态等于已作废的物品不在默认列表展示,可在状态筛选中查看全量”。这种问题非常容易出现在“一个列表接口被多个前端场景复用”的时候,排查思路就是逐步确认:数据在不在库、SQL 条件对不对、前端有没有私加过滤。按这个顺序查,哪怕新手也能很快定位。

6. 扩展方向:二维码标签、自动盘点与多端同步

如果 MVP 跑顺了,后续有几个很自然的扩展方向。

第一个是二维码标签。给每个物品生成一个二维码,贴在收纳盒或者柜子上,用手机扫一下就能看到物品详情。Zxing 库可以直接生成二维码图片,选择在物品登记或编辑时点了“打印标签”就生成一张 PNG,配合 A4 标签纸打印就行。这个扩展能极大缩短找东西的时间,特别适合工具、线材、烘焙模具这些零碎物品。

第二个是“快速盘点”模式。移动端打开页面,逐个扫描二维码或手动点击“确认看到”,后端把盘点结果和数据库记录比对,输出“该在却不在”和“在但没登记”的差异清单。技术上不复杂,就是把二维码的扫描结果映射到 item_id,再做一次批量状态比对,但对用户的价值非常大。

第三个是多端同步。目前这套系统跑在一台家用服务器或电脑上,如果能搭配一个线上的数据库或者用内网穿透方案(注意区域网络合规),就能实现手机、电脑、平板的同步录入和查询。如果不想引入太重的外部依赖,也可以直接使用局域网访问,手机上浏览器打开同一个 IP 的 8080 端口就能用,前提是设备在同一网段。

我自己实际使用下来的感受是:物品管理系统的价值不在功能多炫,而在更新及时。今天买的新东西、借出去的旧东西,当天不录不进系统,系统再强大也是空壳。所以第二个扩展方向里,我更推荐做“快速登记”:从 Excel 导入一批历史物品数据,把这件最痛苦的事解决掉,系统才能真正用起来。

最后再分享一个小技巧:在物品列表增加一列“最近更新时间”,每次盘点或者调整位置时顺手编辑一下,时间一长你就能看到哪些物品半年以上没有动过,那些大概率是吃灰的东西,考虑二手出掉或者送人。这个细节虽然不是系统的功能点,但用下来反而是我做这套系统收获最大的副产品。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进