这套仓库是 SpringBoot + Vue3 + MyBatis 的前后端分离社区防疫物资申报系统,覆盖了物资申报、审核发放、出入库台账、记录归档一整套链路。单看功能,它并不算复杂,但凡是做过管理系统的都知道,这类业务的关键从来不在增删改查,而在库存怎么扣、状态怎么流转、权限怎么约束。这篇文章就围绕系统本身,把项目拆开讲清楚:为什么这么选型、表结构怎么设计、后端哪些代码是核心、前端怎么对接,以及我在部署和调试过程中踩过的坑。想拿它做毕业设计、接手私活,或者单纯想学习三套技术栈如何整合的同学,可以直接参照里面的写法。
1. 项目定位与技术选型逻辑
1.1 社区防疫物资申报系统的核心需求
很多人看到“物资申报系统”第一反应就是“一个表单加一个列表”,但把需求拆细之后完全不是这么回事。这个项目要服务的是社区场景,角色至少分三类:居民、社区工作人员、系统管理员。居民要能注册登录、提交申领、查看审核进度;社区人员要能审核申报、登记物资出入库、修改库存;管理员则要能看到统计报表、管理人员账号。更细一点,物资不止是“个”,可能是按“份”来发,比如一箱蔬菜算一份,一份里面有多种物品;也可能按“桶”“袋”这种非整数单位来管理。不同物资计量单位不一样,申报数量上限不一样,紧急程度也不一样。
所以这类系统的核心需求可以概括成四件事:
- 申报物资的流程要闭环,从提交申请到审核再到发放核销,每一步都必须留痕。
- 库存数据必须可靠,不能出现“界面显示有货、实际一核销就超发”的情况。
- 操作权限要分清,普通用户不能访问管理接口,社区人员不能随意删除台账记录。
- 台账数据要能追溯,哪一批物资什么时候进库、由谁审核、发给了谁,都得能在库里查出来。
这套源码就是按照这四条线来设计的,而不是简单堆 CRUD。
1.2 为什么选 SpringBoot + Vue3 + MyBatis + MySQL
先说后端。SpringBoot 在这个场景下的优势是启动快、配置少、生态成熟。如果用原生 SSM,光 Spring、SpringMVC、MyBatis 三个配置文件就能写半天,而且依赖版本经常互掐。SpringBoot 把 starter 机制一用,依赖问题基本消失,剩下的精力可以全放在业务代码上。对于社区级别的并发量——几十个用户同时申报,其实连高级性能优化都用不太上,SpringBoot 默认的 Tomcat 线程池已经足够。
持久层选 MyBatis 而不是 MyBatis-Plus 或者 JPA,是刻意为之。MyBatis 最大的价值是 SQL 可控。物资扣减这种操作,直接写UPDATE stock SET ... WHERE ...比靠 ORM 自动生成的 Update 语句要放心得多。有些团队喜欢用 MyBatis-Plus 图省事,但它的 API 虽然方便,一旦遇到复杂联表查询、动态条件拼装,最后还是得回到 XML 里手写 SQL。我这个项目把 Mapper 层和 SQL 分离,每一条关键 SQL 都写在 XML 里,后面做代码审查、性能调优都更直观。
前端选 Vue3 的原因比较简单:Composition API 写业务逻辑比 Options API 顺手太多,组件内代码复用度高;而且 Vue3 配合 Vite 启动速度极快,开发体验比 Webpack 时代提升了一个档次。Element Plus 作为 Vue3 官方生态最成熟的组件库,做后台管理界面几乎不用额外造轮子。
数据库选 MySQL 也没什么悬念。社区级系统的数据量在百万行以内,MySQL 8.0 的 InnoDB 引擎、事务支持和索引能力完全够用。考虑到真实环境里很多人还在用 5.7,这个项目的 SQL 也刻意避免用 8.0 独有的窗口函数这类特性,保持了较好的兼容性。
前后端分离的意义在于:一套后端 API 可以供后台管理 Web 端、居民小程序端甚至未来可能的移动 App 共用。只要接口设计得规范,前端换一套只是时间问题,后端业务逻辑不需要跟着动。
2. 数据库设计与核心表结构
2.1 实体关系梳理
这套系统我最终拆成了六张核心表加一张扩展表,比一开始设计时少了两张。一开始我把“物资分类”单独建了表,后来发现分类就三种:防护用品、生活物资、消杀用品,用枚举字段就能解决,完全没必要为了分类建一张表。这也是一个经验:能用枚举解决的绝对不要上关联表,否则查询多一次 JOIN,页面多一次下拉框数据加载,效果却没差别。
核心表包括:
- 用户表,存储账号、姓名、手机号、角色、所属社区/小区、地址。
- 物资表,存储物资名称、分类、计量单位、总库存、剩余库存、预警阈值。
- 申报表,存储哪个人在什么时间申领了哪项物资、数量多少、当前状态、审批人和审批时间。
- 物资出入库记录表,存储每一笔入库、出库、报损的数量及操作人。
- 公告表,存储社区管理人员发布的物资发放通知。
- 操作日志表,记录关键业务动作,方便追溯。
其中申报表和物资表之间是多对一关系,一张申报单只能对应一种物资;出入库记录表和物资表之间也是多对一。用户表和申报表是一对多。整个模型不复杂,但已经覆盖了业务流程需要的全部数据。
2.2 关键表字段与索引设计
这里我把三张最重要的表的建表 SQL 放出来,并说明关键设计点:
CREATE TABLE `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', `username` VARCHAR(50) NOT NULL COMMENT '登录账号', `password` VARCHAR(100) NOT NULL COMMENT '密码密文', `real_name` VARCHAR(20) NOT NULL COMMENT '真实姓名', `phone` VARCHAR(20) NOT NULL COMMENT '手机号', `role` TINYINT NOT NULL DEFAULT 2 COMMENT '角色 0管理员 1社区人员 2居民', `community` VARCHAR(100) DEFAULT NULL COMMENT '所属社区', `address` VARCHAR(200) DEFAULT NULL COMMENT '详细地址', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态 1正常 0禁用', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';用户表的密码字段我用password存 BCrypt 加密后的密文,长度 100 足够。真实姓名和地址是居民申报的必填信息,管理员审核时需要核对。role用 TINYINT,0/1/2 分别对应管理员、社区人员、居民,比字符串更省空间也更快。
CREATE TABLE `material` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `name` VARCHAR(50) NOT NULL COMMENT '物资名称', `category` TINYINT NOT NULL COMMENT '分类 1防护 2生活 3消杀', `unit` VARCHAR(10) NOT NULL DEFAULT '份' COMMENT '计量单位', `total_stock` INT NOT NULL DEFAULT 0 COMMENT '历史入库总量', `remaining_stock` INT NOT NULL DEFAULT 0 COMMENT '剩余库存', `warning_threshold` INT NOT NULL DEFAULT 20 COMMENT '低库存预警阈值', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态 1正常 0下架', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category` (`category`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物资表';物资表的设计重点是区分total_stock和remaining_stock。前者是累计入库总量,用来做统计;后者是当前剩余库存,用来做申报判断。这两个字段绝对不能混用,否则“已发出总量”和“当前库存”永远算不清楚。
CREATE TABLE `apply_record` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `apply_no` VARCHAR(32) NOT NULL COMMENT '申报单号', `user_id` BIGINT NOT NULL COMMENT '申报人ID', `material_id` BIGINT NOT NULL COMMENT '物资ID', `quantity` INT NOT NULL COMMENT '申报数量', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 0待审核 1已通过 2已发放 3已驳回 4已取消', `apply_reason` VARCHAR(255) DEFAULT NULL COMMENT '申请说明', `audit_user_id` BIGINT DEFAULT NULL COMMENT '审核人ID', `audit_time` DATETIME DEFAULT NULL, `audit_remark` VARCHAR(255) DEFAULT NULL COMMENT '审核备注', `delivery_time` DATETIME DEFAULT NULL COMMENT '发放时间', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_apply_no` (`apply_no`), KEY `idx_user_id` (`user_id`), KEY `idx_material_id` (`material_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物资申报记录表';申报单号apply_no我建议用时间戳加随机数的形式生成,例如20250610143000123456,而不是依赖数据库自增 ID。因为自增 ID 很容易被猜到,别人可以通过修改 URL 中的 ID 去遍历别人的申报单。单号用唯一索引约束,业务上追踪也方便。
索引设计有一处最容易忽略:status字段建了单独索引。因为社区人员最频繁的操作就是“查待审核列表”,如果 status 没有索引,后续数据量涨到几万条,每次翻页都会变成全表扫描,页面必卡。
出库记录表的结构类似,这里不贴完整 SQL,重点说一下字段:record_type区分入库和出库,quantity用正数存储,通过 type 判断加减;operator_id记录操作人;remark记录备注,例如“居民申领发放”“库存报损”。这张表的数据只增不改,所有历史记录都保留,是后续对账的唯一依据。
2.3 状态机设计
申报表的状态字段是整个系统的核心逻辑之一。我把状态机定义为:
- 待审核:居民提交申报后的初始状态。
- 已通过:社区人员审核同意,此时库存已被预占。
- 已发放:物资实际发给居民,记录发放时间。
- 已驳回:审核不同意,不涉及库存变动。
- 已取消:居民主动取消,预占库存回补。
状态流转还有一条不变量:从“待审核”变成“已通过”后,不能直接变成“已驳回”;从“已通过”变成“已发放”时,必须校验当前库存仍然充足。这些规则我在 Service 层通过枚举和状态判断统一处理,而不是分散在 Controller 里。
3. 后端核心实现:从登录鉴权到物资防超发
3.1 工程结构规划
后端工程我按标准的 maven 多模块目录来组织,但模块没有拆得很碎,因为拆多了反而增加维护成本。核心结构如下:
src/main/java/com/example/material/ ├── common/ # 通用返回结果、异常处理、常量 ├── config/ # 跨域、拦截器、MyBatis 配置 ├── controller/ # 接口层 ├── service/ # 业务逻辑层 │ └── impl/ ├── mapper/ # MyBatis Mapper 接口 ├── entity/ # 数据库实体类 ├── dto/ # 请求参数对象 └── vo/ # 响应视图对象这里要特别强调dto和vo不能省。很多初学者喜欢直接把 Entity 丢给前端返回,这会带来两个问题:第一,数据库字段暴露过多,比如密码字段被 JSON 序列化出去,这是安全事故;第二,前端需要的字段往往和数据库字段不对应,比如要返回“剩余库存是否低于阈值”这种计算字段,放 Entity 里不合适。所以 Controller 层接收 DTO,返回 VO,Entity 只在 Service 和 Mapper 之间流转。代码量确实会多一点,但安全性和可维护性都更好。
3.2 登录鉴权与角色控制
登录鉴权我最终选择了 Sa-Token 而不是 Spring Security。原因有三点:第一,这个系统的权限模型只有三种角色,用 Spring Security 的过滤器链、UserDetailsService 一大堆配置属于杀鸡用牛刀;第二,Sa-Token 的登录、退出、权限校验 API 极其简洁,几行代码就能完成同样的功能;第三,它默认集成 Redis 做会话存储,如果未来要扩展分布式部署,切换成本几乎是零。
核心实现是自定义一个拦截器,拦截所有/api/**请求,放行登录接口和静态资源:
@Component public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (request.getMethod().equals("OPTIONS")) { return true; } String token = request.getHeader("Authorization"); if (token == null || !StpUtil.isLogin()) { response.setStatus(401); return false; } StpUtil.checkLogin(); return true; } }角色控制上,管理员访问管理接口用@SaCheckRole("admin"),社区人员审核接口用@SaCheckRole("community"),居民端接口只要求登录即可。一个注解搞定权限,不用再手写 if-else 去判断用户类型。
3.3 库存扣减与防超发
这套系统最容易出错的地方就是库存扣减。最粗暴的写法是:
Material material = materialMapper.selectById(materialId); if (material.getRemainingStock() < quantity) { throw new RuntimeException("库存不足"); } material.setRemainingStock(material.getRemainingStock() - quantity); materialMapper.updateById(material);这段代码在单用户场景下没问题,但一旦两个居民同时提交申报,就可能出现“共同通过校验、一起扣减库存、最终库存变负”的问题。原因是先查后改的操作不是原子性的。
正确的做法是加乐观锁或者行锁。我这里用的是条件更新,把判断库存充足和扣减库存放在一条 SQL 里完成:
<update id="deductStock"> UPDATE material SET remaining_stock = remaining_stock - #{quantity} WHERE id = #{materialId} AND remaining_stock >= #{quantity} </update>然后在 Service 层判断返回的行数,如果受影响行数为 0,说明库存不足,直接抛业务异常:
@Transactional(rollbackFor = Exception.class) public void approveApply(Long applyId) { ApplyRecord apply = applyMapper.selectById(applyId); if (apply == null || apply.getStatus() != 0) { throw new BusinessException("申报单不存在或状态不正确"); } int rows = materialMapper.deductStock(apply.getMaterialId(), apply.getQuantity()); if (rows == 0) { throw new BusinessException("物资库存不足,无法通过"); } apply.setStatus(1); applyMapper.updateStatus(apply); }这里有几个细节必须注意:
deductStock的 UPDATE 语句在 InnoDB 里对目标行加了排他锁,并发情况下后到的事务会等待前一个事务提交,所以即使两个请求同时进来,最终也只有一个能扣减成功。@Transactional必须加在 Service 方法上。如果不加,deductStock执行成功后后续更新状态报错,库存已经扣了,数据就变成对不上账。- 状态判断也必须在扣减之前完成。如果一笔申报已经审核过了,再次调用接口要能拦截住,避免重复扣库存。
3.4 申报审核流程的完整实现
审核接口的完整流程包含校验申报状态、扣减库存、更新审核信息、写入日志四个步骤。上面的代码展示了核心逻辑。取消申报的逻辑则相反,要把预占库存回补回去:
@Transactional(rollbackFor = Exception.class) public void cancelApply(Long applyId, Long userId) { ApplyRecord apply = applyMapper.selectById(applyId); if (apply == null || !apply.getUserId().equals(userId)) { throw new BusinessException("申请记录不存在或无权操作"); } // 只有待审核状态可以直接取消 if (apply.getStatus() != 0) { throw new BusinessException("当前状态不可取消"); } apply.setStatus(4); applyMapper.updateStatus(apply); // 如果已经部分流程走了其他状态,库存处理由相应接口负责 }3.5 统一返回与全局异常处理
后端接口的返回结构我统一用Result<T>封装:
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { // 省略实现 } public static <T> Result<T> error(Integer code, String message) { // 省略实现 } }同时用@RestControllerAdvice统一捕获异常,业务异常返回 code 400,系统异常返回 code 500,校验异常返回 code 422。前端只需要根据 code 做统一的判断和提示,不需要在每个请求里单独处理错误分支。
4. 前端工程落地:Vue3 页面与接口联调
4.1 前端项目结构与目录划分
前端我用 Vite + Vue3 + Pinia + Element Plus 的组合。Vite 的冷启动速度相比 Webpack 提升非常明显,几乎是秒级启动,改代码之后的热更新也流畅很多。目录结构如下:
src/ ├── api/ # 接口定义,按模块拆分 ├── assets/ # 静态资源 ├── components/ # 公共组件 ├── router/ # 路由配置 ├── stores/ # Pinia 状态管理 ├── utils/ # 工具函数,axios 封装 └── views/ # 页面组件接口定义单独放在api目录而不是直接在页面里写请求,是我一直坚持的习惯。一个接口可能被多个页面复用,集中管理之后,后端路径变更只需要改一处,而且配合 IDE 的代码提示,写起来效率高很多。
4.2 核心页面拆解
系统核心页面包括:
- 登录页面
- 居民首页,展示公告和物资列表,提供申报入口
- 我的申报页面,展示历史申报记录及审核状态
- 物资管理页面,管理员维护物资信息
- 审核页面,社区人员处理待审核申报
- 出入库登记页面,登记物资进出
- 统计面板,用卡片展示关键指标,如今日申报数、待审核数、库存预警数
登录页逻辑不复杂,重点提一下 Vue3 表单校验和状态持久化。userInfo和token都放到 Pinia 里,同时持久化到 localStorage。每次刷新页面时从 localStorage 恢复状态,避免页面一刷新就跳回登录页。
4.3 axios 封装与接口调用规范
axios 封装是前端工程质量的关键点。我在utils/request.js里做了统一处理:
import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = token } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code === 200) { return res } if (res.code === 401) { localStorage.removeItem('token') router.push('/login') return Promise.reject(new Error('未登录或登录已过期')) } ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) }, error => { ElMessage.error(error.message || '网络异常') return Promise.reject(error) } )Vue3 页面里的 API 调用我再封装一层,例如src/api/apply.js中如下定义方法:
import request from '@/utils/request' export function submitApply(data) { return request.post('/apply/submit', data) } export function getMyApplyList(params) { return request.get('/apply/my', { params }) } export function cancelApply(id) { return request.put(`/apply/cancel/${id}`) }这里要说一个容易踩的坑:axios 拦截器里我处理的是response.data.code,但下载接口和文件导出接口的响应不是 JSON 结构,如果统一走这个拦截器,文件流会被解析错。后来我单独在下载请求里加了responseType: 'blob',并在拦截器里判断response.request.responseType === 'blob'时直接返回 response。这个小分支建议从一开始就处理好,不然后面导出报表时必翻车。
5. 部署运行与数据库初始化
5.1 环境准备
本地运行这套系统需要以下环境:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| JDK | 17 及以上 | SpringBoot 2.7 之后对 JDK17 支持良好 |
| Maven | 3.8+ | 管理后端依赖 |
| Node.js | 18 及以上 | 运行 Vue3 前端工程 |
| MySQL | 8.0 或 5.7 | 项目使用 MySQL 8.0 开发,兼容 5.7 |
| Redis | 无需强制 | Sa-Token 如果用默认内存模式可跳过 Redis |
启动前先确认 MySQL 服务已经启动,并创建数据库:
mysql -uroot -p CREATE DATABASE IF NOT EXISTS community_material DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE community_material; SOURCE /path/to/community_material.sql;5.2 Spring Boot 配置要点
后端application.yml配置里需要特别注意数据源和 Sa-Token 的参数:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/community_material?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: yourpassword jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.material.entity configuration: map-underscore-to-camel-case: true sa-token: token-name: Authorization timeout: 86400 is-concurrent: falsemap-underscore-to-camel-case: true这行非常重要。数据库字段是remaining_stock,Java 实体属性是remainingStock,如果没有开启驼峰映射,所有查出来的字段都会是 null,而且这种问题表面看不出报错,只有跑到具体业务才会发现数据是空的。
5.3 前端代理与跨域配置
前后端分离后,本地开发必然遇到跨域问题。我在 Vite 配置里用代理解决:
export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })这样前端所有/api开头的请求会被 Vite 转发到后端 8080 端口,开发环境下根本没有跨域问题,不需要在后端额外开放 CORS。生产环境部署时,把前端构建产物放到 Nginx 里,再配置 Nginx 反向代理到后端服务,同样可以避免跨域。
5.4 打包部署流程
后端打包:
mvn clean package -DskipTests java -jar target/community-material-0.0.1-SNAPSHOT.jar前端打包:
npm install npm run build构建完成后,dist目录就是可部署的静态文件。生产环境建议用 Nginx 托管前端静态文件,并将/api反向代理到后端地址:
location /api/ { proxy_pass http://127.0.0.1:8080/api/; }6. 我遇到的常见问题与排坑记录
6.1 数据库连接初始化失败
最常见的问题,没有之一。启动后端直接报Failed to initialize pool,十有八九是数据库没连上。排查步骤很简单:先在命令行用同样的账号密码mysql -uroot -p试一下,如果命令行能连,Java 连不上,大概率是时区或者 SSL 配置有问题。serverTimezone=Asia/Shanghai和useSSL=false这两个参数确保加了基本不会再报。
6.2 Mapper 绑定异常
Invalid bound statement (not found)出现时,第一反应检查mapper-locations路径是否与实际 XML 文件位置一致。我遇到过把 XML 放在src/main/java目录下但没有配置 resources 识别的情况,Maven 默认只把src/main/resources当作资源目录,如果你把 XML 放在src/main/java/.../mapper/下,需要额外在pom.xml里配置<resources>包含 xml 文件,否则打包后 XML 根本不在 jar 里。这也是初学者最容易忽略的点。
6.3 JSON 循环引用导致接口返回失败
如果 Entity 中写了双向关联关系,例如用户表关联申报列表、申报列表又关联用户对象,序列化时就可能出现无限递归。我没有选择加@JsonIgnore这种治标不治本的办法,而是干脆不在 Entity 里写复杂关联,全部通过 Mapper 查询时使用 VO 类来做扁平化映射。实体类保持纯净,不仅是预防循环引用,也是规范问题。
6.4 前端刷新页面出现 404
Vue3 使用 history 模式路由时,部署到 Nginx 之后刷新子路由页面会出现 404。原因很简单:Nginx 找不到对应的物理文件路径,需要把所有路由回退到index.html。Nginx 配置加一句:
location / { try_files $uri $uri/ /index.html; }如果用了 hash 模式,则不会有这个问题,但 URL 会多一个#,我不是很喜欢。
6.5 并发扣库存导致数据负数
这个问题我上面已经详细讲了解决方案,这里再补充一个排坑经验。一开始我也没加条件更新,用的是“先查后改”,结果测试时用 JMeter 模拟 20 个并发请求,库存从 10 直接被扣到 -10。后来改成条件更新,再跑一次测试,只有 10 个请求成功,其余全部返回库存不足,数据始终保持非负。这个结果直接说明问题不在“查询时判断不足”,而在“扣减动作和判断动作必须是一条原子操作”。
6.6 物资台账与库存数据不一致
排查这类问题,重点看四类操作有没有加事务:审核通过扣库存、取消申报回补库存、出库扣库存、入库加库存。如果任何一处忘了加@Transactional,并且中途发生异常,库存和记录就会脱节。另外操作日志表和库存变动必须写在同一事务里,否则日志丢了,后面对账完全无从谈起。
6.7 前端 Element Plus 表格数据量过大导致卡顿
物资出入库记录表的数据量短期内不会极大,但一直累积也会有十万行以上。Element Plus 表格直接渲染全部数据会明显卡顿。我的建议是分页查询接口端处理,前端每次只加载一页;如果非要展示大量数据,用虚拟表格或者开启表格的懒加载模式。不要试图一次性把全量数据都塞到前端再分页,这种写法在数据量小时没感觉,一上来就卡死。
7. 从这套源码里可以学到的工程习惯
回到开头说的,这不是一个炫技项目。但把整个链路从零到完整地走通一遍,能沉淀下来的东西其实不少。
第一个收获是事务边界的控制。库存类系统里,事务加在哪里、粒度怎么控制,直接决定数据是否可靠。一开始我把@Transactional加到 Controller 方法上,结果 AOP 代理根本不生效——事务就没开启过。后来了解到 Spring 事务默认只在 public 方法上生效、通过代理对象调用内部方法不经过代理等细节,才真正弄明白这套机制。这类知识看文档十遍不如出一个事故记得牢。
第二个收获是接口设计的稳定性。前端对接阶段最痛苦的事不是写前端代码,而是后端接口频繁变更。今天返回remaining_stock,明天改成stock,前端就得跟着改。所以后端接口在定稿前,一定要先跟前端对齐字段命名、数据类型和返回结构。好的接口设计能减少大量联调成本,而糟糕的接口设计会让整个项目陷入无尽的修改循环。
第三个收获是面对边界情况的处理。物资系统中“库存恰好为 0”“同一用户同时提交两单”“审核人把自己报的申请通过”都属于边界场景。多想一想这些情况,代码的健壮性会有质的提升。测试的时候不要总想着“正常流程能跑通”,要主动去造异常,比如把库存改成 1 却申请 2,把状态改成已发放再点通过,这些都是常规文档里不会专门讲、但实际运行中必然遇见的问题。
我在实际调试过程中还有一个体会:这套代码真正难点不在于某个技术点本身有多深,而在于多个技术栈配合时产生的细节问题太多了。数据库时区差八小时、前端代理路径漏了一个斜杠、Maven 打包没包含 XML 文件、Nginx 刷新 404……每一个单独拿出来都不是大问题,但排起来一个晚上就过去了。如果读者刚把这套系统跑起来,脑子里最好带着一个念头:凡是感觉不对劲的地方,第一步先看日志,第二步去看真实请求的入参和出参,第三步再去猜代码逻辑,按照这个顺序排查基本不会白费力气。
后续如果还想继续扩展这个项目,可以考虑加一个轻量级的 Redis 缓存来扛社区公告和物资列表的高频读取,或者增加报表导出功能,把出入库记录按月份汇总成 Excel。功能层面我已经预留了扩展接口,直接在现有结构上做加法不会伤筋动骨。源码里提到的每个模块,拿到手最好都自己动手改一遍,改出问题再解决,比照着跑通一遍的收获要大得多。