☰
社区防疫物资申报系统实战:SpringBoot+Vue全栈设计与实现
2026/10/17 8:04:30 网站建设 项目流程

最近有不少同学和同行来问我,毕设或者练手项目用“社区防疫物资申报系统”这种选题到底怎么落地,今天就把我这个项目的完整思路、表结构设计、前后端核心代码和踩坑记录一次性整理出来。项目技术栈是SpringBoot + Vue + MySQL + MyBatis,典型的Java全栈单机应用,既有完整的管理端又有居民申报端,覆盖了物资申报、审批、入库、发放、库存预警和统计报表一整条链路,很适合拿来作为课程设计、毕业设计或者想熟悉前后端分离开发模式的入门实战项目。

这套系统的核心价值在于:它不是一个单纯的CRUD Demo,而是把社区物资管理里最麻烦的“申报→审核→发放→库存核减”闭环通过状态机串了起来,同时在权限上区分了居民用户、社区工作人员和管理员三种角色,业务上还考虑了重复申报、库存不足、批次过期这类真实场景。整个项目不需要额外中间件,MySQL建库后直接跑起来,前端Vue工程用npm启动,后端SpringBoot工程改一下数据源配置就行,复制即用的友好度相当高。

1. 需求拆解与功能模块设计

1.1 这套系统到底在解决什么问题

社区防疫物资管理的痛点,不在于“登记”,而在于“分配要公平、库存要透明、流程要留痕”。如果只用Excel表格来管理,很快就会遇到三个问题:一是居民提交的物资需求和实际发放结果对不上;二是工作人员无法实时知道仓库里还剩多少货,谁多领了谁没领到全靠人肉记忆;三是事后要做统计报表,只能翻聊天记录和纸上记录,效率极低。

所以系统在设计时,我把它拆成两条主线。第一条是居民侧,用户登录后能看到物资清单、提交申报、查看审核进度和领取记录;第二条是管理侧,工作人员要能处理申报单、登记入库批次、手动出库、调整库存,管理员还能看到各物资的库存趋势和申报统计。这两条线合在一起,就形成了申报单从“提交”到“审核通过”再到“发放完成/库存扣减”的完整闭环。

权限上,我用了最简单的基于角色的方式,三张表对应三个角色:居民表、工作人员表、管理员表,登录后通过角色字段跳转到不同的首页布局。这样设计的好处是不用引入Spring Security的复杂配置,适合教学和毕设场景,后面如果要加权限框架,也能平滑迁过去。

1.2 功能模块拆分与页面清单

整个系统我分成六个功能域:登录认证、物资台账管理、申报管理、出入库管理、库存预警、数据统计。

  • 登录认证:账号密码校验、角色判断、会话保持。
  • 物资台账:物资类别(口罩、消杀用品、生活物资等)、物资名称、规格单位、安全库存阈值。
  • 申报管理:居民提交申报单(多物资明细)、工作人员审核通过/驳回、审核通过的申报单关联到发放批次。
  • 出入库管理:入库单登记(含生产日期、过期日期)、出库单(关联申报单或个人领取),库存流水记录每一笔变动。
  • 库存预警:当前库存低于安全阈值时,在管理首页和高危物资列表里高亮提示。
  • 数据统计:按日期统计申报数量、发放数量、库存余量,用简单的柱状图展示,前端用ECharts。

页面清单方面,居民端有物资大厅、我的申报、我的领取记录三个页面;管理端有工作台、申报审核、物资管理、入库登记、出库登记、库存流水、预警中心、统计报表;再加一个管理员专用的用户与角色管理页。一共十几个页面,工作量适中,但覆盖了比较完整的业务语义,跟“管理系统”这一定位是吻合的。

2. 数据库设计与后端核心实现

2.1 表结构设计与关系梳理

数据库这块是项目的地基,我一开始就定下了一个原则:表结构要能支撑状态流转,而不是简单堆字段。总共设计了9张表,分别是:

表名用途关键字段
user用户统一表(含角色字段)username, password, role
material物资台账name, category, spec, unit, stock, safety_stock
material_category物资类别name, sort_order
stock_record库存流水material_id, change_type, change_quantity, before_stock, after_stock
inbound_order入库单supplier, operator_id, remark, status
inbound_item入库明细inbound_id, material_id, quantity, production_date, expire_date
declaration申报单主表user_id, status, reviewer_id, review_time, total_quantity
declaration_item申报明细declaration_id, material_id, quantity, issued_quantity
outbound_record出库记录declaration_id, material_id, quantity, receive_user_id, receive_time

用户表合并成一个,用role字段区分角色,这样登录逻辑简单,代价是在业务代码中要多做角色判断。这个取舍对中小项目是划算的,如果你要追求强类型约束,也可以拆成三张表,但代码里就要多三个Mapper和三次联表查询,反而是负担。

库存流水表是整个设计里最重要的表,它记录了每一次库存变动的before和after值,这个设计让我在排查数据问题时省了大力气。任何库存对不上账的情况,只需看流水表就能定位到具体是哪一笔操作出了问题,而不是靠猜。

申报单和申报明细拆分是一对多关系的标准做法。一个申报单可以同时申请口罩和消毒液两种物资,明细表里每行对应一种物资的申报数量,审核通过后在issued_quantity字段记录实际批准数量,这个字段的存在意义是:审核时可以部分同意,而不是只能整体通过或整体驳回。

2.2 SpringBoot后端工程结构与核心代码

工程结构我按标准三层架构组织:controller → service → mapper,entity放在domain包。Controller只做参数接收和结果封装,业务逻辑全部下沉到Service层,这样测试和排查都很方便。

以申报提交接口为例,核心逻辑如下:

@PostMapping("/api/declaration/submit") public Result submit(@RequestBody DeclarationSubmitDTO dto, HttpServletRequest request) { Long userId = SessionUtil.getLoginUserId(request); // 1. 校验用户是否存在且未被禁用 User user = userMapper.selectById(userId); if (user == null || user.getStatus() != 1) { return Result.error("用户状态异常,无法申报"); } // 2. 校验明细数量以及库存是否足够 for (DeclarationItemDTO itemDto : dto.getItems()) { Material material = materialMapper.selectById(itemDto.getMaterialId()); if (material == null) { return Result.error("物资不存在:" + itemDto.getMaterialId()); } if (itemDto.getQuantity() <= 0) { return Result.error("申报数量必须大于0"); } // 这里只是预校验,锁定库存交给service层事务处理 if (material.getStock() < itemDto.getQuantity()) { return Result.error("库存不足:" + material.getName()); } } declarationService.submitOrder(dto, userId); return Result.ok("申报成功,等待审核"); }

这种参数校验放在Controller层的写法,对于一个非企业级项目来说够用,但更规范的做法是使用JSR-303注解做字段级校验,再配合全局异常处理器统一返回格式。我建议你在答辩时主动提一下“这里可以用@Valid注解优化”,会显得考虑更全面。

Service层的核心方法是submitOrder,这个方法标注了@Transactional,因为要同时插入主表和明细表,任何一步失败都要回滚,否则会出现申报单没有明细的脏数据。

@Transactional(rollbackFor = Exception.class) public void submitOrder(DeclarationSubmitDTO dto, Long userId) { Declaration declaration = new Declaration(); declaration.setUserId(userId); declaration.setStatus(DeclarationStatus.PENDING); // 待审核 declaration.setCreateTime(new Date()); declarationMapper.insert(declaration); for (DeclarationItemDTO itemDto : dto.getItems()) { DeclarationItem item = new DeclarationItem(); item.setDeclarationId(declaration.getId()); item.setMaterialId(itemDto.getMaterialId()); item.setQuantity(itemDto.getQuantity()); item.setIssuedQuantity(0); // 初始未发放 declarationItemMapper.insert(item); } }

这里有一个我在实际使用中踩过的坑:事务只保证数据库操作的原子性,但如果在事务里先查询库存再扣减库存,并发情况下会超卖。这个系统是社区内部使用,并发量不大,所以我没做Redis锁或者数据库乐观锁。但如果面向更大规模场景,一定得在material表加一个version字段,用乐观锁或SELECT FOR UPDATE来处理扣库存。

2.3 MyBatis的XML映射与动态SQL技巧

MyBatis的强大之处在于动态SQL,尤其是申报列表这种多条件组合查询,用注解方式写起来很痛苦,用XML就很舒服。我习惯把所有复杂查询都放在XML文件里,Mapper接口只写方法签名。

以申报单分页查询为例,这段SQL同时支持按状态、按物资名称关键词筛选:

<select id="selectDeclarationPage" resultType="com.demo.domain.DeclarationVO"> SELECT d.id, u.username AS userName, d.status, d.create_time AS createTime, d.review_time AS reviewTime, IFNULL(SUM(di.quantity), 0) AS totalQuantity FROM declaration d LEFT JOIN user u ON d.user_id = u.id LEFT JOIN declaration_item di ON d.id = di.declaration_id <where> <if test="status != null and status != ''"> AND d.status = #{status} </if> <if test="keyword != null and keyword != ''"> AND u.username LIKE CONCAT('%', #{keyword}, '%') </if> </where> GROUP BY d.id, u.username, d.status, d.create_time, d.review_time ORDER BY d.create_time DESC </select>

这里要注意一个细节:关联明细表后做分组统计,必须把SELECT中所有非聚合列都写进GROUP BY,否则MySQL的ONLY_FULL_GROUP_BY模式会直接报错。我第一次写这段SQL时就漏了d.review_time列,在本地跑得好好的,一部署到别的环境就报错,后来查了SQL_MODE才明白原因。

3. 前端Vue页面实现与联调细节

3.1 前端工程结构与请求封装

前端我使用的是Vue2 + ElementUI + axios + ECharts,之所以不用Vue3,不是因为Vue3不好,而是这套课程设计与大部分网上的资料、答辩Demo都基于Vue2,遇到问题能搜到更多现成答案。工程入口在src目录,按views、components、api、router四个目录组织。

axios请求封装是整个前端联调的基石,我在api/request.js里做了一层统一拦截:

import axios from 'axios' import { Message } from 'element-ui' 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['token'] = token } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { Message.error(res.message || '请求失败') if (res.code === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(new Error(res.message)) } return res }, error => { Message.error('网络异常,请稍后重试') return Promise.reject(error) } ) export default service

这里有两个我建议你直接抄作业的细节。第一个是登录状态用token而不是cookie,如果是纯前后端分离项目,后端可以在登录成功后生成一个UUID写入redis,设置过期时间;如果不想引入redis,也可以用JWT,但注意JWT没有服务端主动失效能力,社区管理系统这种内部工具其实用内存Session或简单token都行。第二个是统一响应结构,后端所有接口返回Result对象,包含code、message、data三个字段,前端拦截器统一判断code,这样每个页面都不需要重复写错误处理。

3.2 居民申报页面的实现

物资大厅页面是居民用户最常用的页面,展示所有物资卡片,点击“申报”按钮弹出对话框,选择数量后提交。这里有个交互细节:用户选择数量时,前端要实时显示该物资当前剩余库存,并且限制不能超过库存数,否则用户提交后必然被驳回,体验很差。

<template> <div class="material-hall"> <el-row :gutter="20"> <el-col v-for="item in materialList" :key="item.id" :span="6"> <el-card class="material-card"> <div class="material-name">{{ item.name }}</div> <div class="material-info"> <span>库存:{{ item.stock }} {{ item.unit }}</span> <span v-if="item.stock < item.safetyStock" class="danger-text">库存不足</span> </div> <el-input-number v-model="applyMap[item.id]" :min="1" :max="item.stock" /> <el-button type="primary" @click="submitApply(item)">提交申报</el-button> </el-card> </el-col> </el-row> </div> </template>

提交时,前端要把多选物资组装成一个数组传给后端。这里最常见的坑是Vue响应式问题:如果用this.applyMap = {}这种整体赋值,Vue2监听不到对象内部新增属性变化,必须用this.$set或者提前在data里初始化所有物资的quantity字段。我在联调时被这个问题坑了半小时,界面一直不更新,后来才想起Vue2的Object.defineProperty限制。

另一个细节在申报记录页面:列表展示时要根据状态字段显示不同的按钮,待审核状态可以“撤回”,已审核状态只能“查看详情”,已发放状态要能“确认收货”。这些按钮的显示逻辑不要写在模板里一堆v-if,建议封装成一个computed函数,返回按钮配置数组,模板循环渲染,代码会干净很多。

3.3 ElementUI表单校验与日期组件兼容

管理端的入库登记页面需要选择生产日期和过期日期,ElementUI的日期选择器date-picker默认返回的是一个Date对象,如果直接提交给后端,后端用LocalDateTime接收会报格式错误。我的方案是前端在提交时格式化:

const formData = { ...this.form, productionDate: this.formatDate(this.form.productionDate), expireDate: this.formatDate(this.form.expireDate) } formatDate(date) { if (!date) return null const y = date.getFullYear() const m = String(date.getMonth() + 1).padStart(2, '0') const d = String(date.getDate()).padStart(2, '0') return `${y}-${m}-${d}` }

表单校验这里有个心得,入库数量必须大于0,过期日期必须晚于生产日期,这些业务校验除了前端做,后端Service层也做了一遍。我见过很多项目只做前端校验,直接通过Postman绕过前端就能提交非法数据,这种风险在答辩演示时一旦被问“如果绕过前端怎么办”,会非常被动。

4. 核心业务流程的代码实现剖析

4.1 申报审核与库存扣减的并发安全

审核通过这个动作是系统里最核心的业务,它的操作分为两步:第一步把申报单状态改为“已通过”,第二步把每个明细项的物资库存扣减,并记录库存流水。这两步必须在一个事务里完成,否则会出现申报单显示已通过,但库存没扣减的严重不一致。

@Transactional(rollbackFor = Exception.class) public void approveDeclaration(Long declarationId, Long reviewerId) { Declaration declaration = declarationMapper.selectById(declarationId); // 状态校验:只有待审核状态才能审核 if (!DeclarationStatus.PENDING.equals(declaration.getStatus())) { throw new BusinessException("当前状态不可审核"); } List<DeclarationItem> items = declarationItemMapper.selectByDeclarationId(declarationId); // 先检查所有明细的库存是否充足 for (DeclarationItem item : items) { Material material = materialMapper.selectById(item.getMaterialId()); if (material.getStock() < item.getQuantity()) { throw new BusinessException("库存不足:" + material.getName()); } } // 逐条扣减库存,并写入流水 for (DeclarationItem item : items) { materialMapper.decreaseStock(item.getMaterialId(), item.getQuantity()); stockRecordMapper.insert(new StockRecord( item.getMaterialId(), StockChangeType.OUTBOUND, item.getQuantity(), materialMapper.selectById(item.getMaterialId()).getStock() + item.getQuantity(), materialMapper.selectById(item.getMaterialId()).getStock() )); } declaration.setStatus(DeclarationStatus.APPROVED); declaration.setReviewerId(reviewerId); declaration.setReviewTime(new Date()); declarationMapper.updateById(declaration); }

这段代码我写得比较啰嗦,每次扣减库存都重新查询一次material,真实项目里应该在循环里维护一个Map缓存Material对象,减少数据库查询次数。但这样写的好处是每行逻辑都非常直白,答辩时通俗易懂。如果要优化,先并行把库存查出来放到内存,再统一扣减,性能能提升不少。

关于并发安全的进一步思考,我在这里多说一句。扣减库存用UPDATE语句的原子性可以避免超卖:

UPDATE material SET stock = stock - #{quantity} WHERE id = #{id} AND stock >= #{quantity}

这种写法比先SELECT再UPDATE安全得多,即使两个事务同时执行,数据库的行锁也会保证只有一个操作成功。不过UPDATE影响行数为0时Java代码怎么感知,需要Mapper返回int值,成功为1失败为0,然后手动抛异常回滚。我在项目里是两套方案都写了,主流程用MyBatis的updateById配合乐观锁version,演示时重点讲这个模型就行。

4.2 出库发放与批次过期联动

社区物资和普通商品不一样,很多物资(比如消杀用品)有保质期,管理上要遵循“先进先出”原则。出库时,我设计了一个根据过期日期排序的查询,优先发过期的近期批次:

public List<InboundItemVO> selectExpiringFirst(Long materialId, Integer needQuantity) { // 按过期时间升序查询有剩余库存的入库批次 return inboundItemMapper.selectAvailableBatches(materialId); }

对应的SQL是:

<select id="selectAvailableBatches" resultType="com.demo.domain.vo.InboundItemVO"> SELECT ii.id, ii.inbound_id, ii.quantity AS total_quantity, IFNULL(SUM(oi.out_quantity), 0) AS out_quantity, ii.expire_date FROM inbound_item ii LEFT JOIN outbound_goods oi ON ii.id = oi.inbound_item_id WHERE ii.material_id = #{materialId} AND ii.expire_date >= CURRENT_DATE AND ii.quantity > IFNULL(SUM(oi.out_quantity), 0) GROUP BY ii.id, ii.quantity, ii.expire_date ORDER BY ii.expire_date ASC </select>

这段SQL里最烦人的是LEFT JOIN后求已出库数量的聚合,GROUP BY遗漏字段的问题再次出现。批次剩余数量等于入库数量减去已出库数量,这个计算逻辑放在Java层会更清晰,SQL只负责把批次按过期时间排序取出来。这也是一个经验:复杂的统计逻辑别硬塞给SQL,Java层做一遍运算,调试时打日志能看得很清楚。

4.3 统计报表的数据组装策略

统计报表模块是答辩时的加分项。我的实现方式是后端提供三个原始数据的接口,前端用ECharts渲染,不做后端聚合,减少沟通成本。

三个接口分别是:近7天申报数量趋势、各物资当前库存对比、各类别申报占比。以近7天申报数量趋势为例,SQL写法是用日期分组:

SELECT DATE(create_time) AS report_date, COUNT(*) AS count FROM declaration WHERE create_time >= DATE_SUB(CURDATE(), INTERVAL 6 DAY) GROUP BY DATE(create_time) ORDER BY report_date

但这样做有一个小问题:某一天没有订单,这一天就不会出现在结果里,ECharts的x轴就会缺一个点,图表看起来是断的。解决办法是在Java代码里补全缺失日期,把查询结果转换成一个Map,然后遍历最近7天,有数据取数据,没数据补0。这个小细节能让图表好看很多,也能体现你考虑问题全面。

5. 常见问题与排查技巧实录

5.1 前后端联调时的跨域与会话问题

前后端分离项目第一坑永远是跨域。我把前端Vue工程跑在8080端口,后端SpringBoot跑在8081端口,直接用axios请求必然报跨域错误。解决方案是在后端写一个CORS配置类:

@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOrigin("http://localhost:8080"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }

调试的时候我曾经用@CrossOrigin注解每个Controller,但后来发现这样有两个坑:一是注解分散在各个类里,漏标就报错;二是@CrossOrigin和Spring Security的过滤器链配合时,预检请求会被安全拦截器挡住。统一用CorsFilter这类全局配置才是正解。

另外一个容易忽略的点是,SpringBoot默认的Session在前后端分离下没有意义,因为axios每次请求是独立的。我最后用的是token方案,登录后后端返回一个随机token,前端存localStorage,每次请求在拦截器里带上,后端用一个拦截器校验。注意跨域配置里allowCredentials(true)和Origin通配符不能同时用,浏览器会拒绝这种不安全配置,这个坑排查了我一个晚上。

5.2 数据库连接与时间字段的时区问题

MySQL数据库连接串一定要加上serverTimezone=Asia/Shanghai,否则Java 8的LocalDateTime类型映射会报错:

jdbc:mysql://localhost:3306/community_material?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai

不加这个参数时,在高版本MySQL驱动下会抛出SQLException提示时区无法识别,或者你插入的时间比实际时间少8小时。我记得有一次测试时,前端显示的申报时间是早上10点,数据库里存的是凌晨2点,排查半天才发现是服务器默认时区用的UTC。

5.3 常见问题速查表

现象可能原因解决方案
前端请求404后端接口路径与前端api路径不一致检查@RequestMapper和axios路径是否少/api前缀
登录成功后刷新页面失效token未存储到localStoragelocalStorage.setItem持久化,路由守卫里读取
库存数量变成负数扣减库存未加库存充足条件UPDATE SQL加 stock >= #{quantity}
申报单审核后明细丢失明细插入与主表未在同一事务提交方法加@Transactional
ECharts图表不显示日期缺失日期未补0Java层补全日期Map
Vue列表新增数据不刷新Vue2响应式限制使用this.$set或重新赋值数组
MySQL报ONLY_FULL_GROUP_BY错误GROUP BY列不完整检查SELECT中所有非聚合列

5.4 环境部署的关键顺序

很多人在本地跑不起来这个项目,其实不是代码问题,是环境顺序问题。我的建议是先启动MySQL并执行项目附带的sql脚本,再启动后端SpringBoot应用,等控制台出现“Started Application”字样后,再用npm install && npm run serve启动前端。

后端启动前要检查三件事:MySQL服务是否在运行、application.yml里的账号密码是否匹配、数据库名是否与脚本一致。前端启动后要确认访问的是http://localhost:8080而不是8081,然后通过Vue的proxy代理把/api请求转发到8081。最简单的方式是在vue.config.js里配置devServer代理:

module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8081', changeOrigin: true } } } }

这样前端请求/api/xxx就会自动转发到后端8081,既避免了跨域问题,又不需要改动axios的baseURL。我第一次做这个项目时没配置代理,直接写全路径,结果每次环境切换都要改几十处代码,后来改成代理后才彻底解放。

6. 一段经验总结:做完这个项目我学到的

这个系统从数据库建模到前后端跑通,我前后花了大概两周的业余时间。回头来看,最有价值的部分倒不是某个功能写了多少代码,而是对“状态机”这个概念有了真正的体感。申报单的待审核、已通过、已驳回、已发放、已撤回这五个状态,看似简单,实际上每一处状态转换都要考虑前置条件和数据一致性,比如已驳回的订单不能再审核,已发放的订单不能撤回。

如果你也想拿类似项目做练习,我的建议是先画一张状态流转图,把每个状态的前置、后置条件和涉及的表操作理清楚,再动笔写代码。另外,代码注释不要写流水账,而是在关键业务逻辑旁写清楚“为什么这么做”,比如扣库存为什么加version字段、审核为什么需要先校验全部明细再统一扣减,这些注释在答辩或者写简历时就是现成的项目亮点。

最后再分享一个小技巧:这个项目后续可以继续扩展的方向还挺多的,比如把统计报表从最简单的柱状图升级为按物资类别、按时间范围的组合筛选,比如引入Excel导入导出功能,把出入库单批量导入,再比如把审核功能加一个多人审批流。我当时只做了单人审核,如果你时间充裕,把审批流做成两级审批,整个项目的复杂度会立刻上一个台阶,放到简历里也更有说服力。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询