资讯动态

社区防疫物资申报系统实战:SpringBoot+Vue全栈设计与实现

发布时间:2026/10/17 8:04:41 来源:尧图企业网站定制
最近有不少同学和同行来问我毕设或者练手项目用“社区防疫物资申报系统”这种选题到底怎么落地今天就把我这个项目的完整思路、表结构设计、前后端核心代码和踩坑记录一次性整理出来。项目技术栈是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, rolematerial物资台账name, category, spec, unit, stock, safety_stockmaterial_category物资类别name, sort_orderstock_record库存流水material_id, change_type, change_quantity, before_stock, after_stockinbound_order入库单supplier, operator_id, remark, statusinbound_item入库明细inbound_id, material_id, quantity, production_date, expire_datedeclaration申报单主表user_id, status, reviewer_id, review_time, total_quantitydeclaration_item申报明细declaration_id, material_id, quantity, issued_quantityoutbound_record出库记录declaration_id, material_id, quantity, receive_user_id, receive_time用户表合并成一个用role字段区分角色这样登录逻辑简单代价是在业务代码中要多做角色判断。这个取舍对中小项目是划算的如果你要追求强类型约束也可以拆成三张表但代码里就要多三个Mapper和三次联表查询反而是负担。库存流水表是整个设计里最重要的表它记录了每一次库存变动的before和after值这个设计让我在排查数据问题时省了大力气。任何库存对不上账的情况只需看流水表就能定位到具体是哪一笔操作出了问题而不是靠猜。申报单和申报明细拆分是一对多关系的标准做法。一个申报单可以同时申请口罩和消毒液两种物资明细表里每行对应一种物资的申报数量审核通过后在issued_quantity字段记录实际批准数量这个字段的存在意义是审核时可以部分同意而不是只能整体通过或整体驳回。2.2 SpringBoot后端工程结构与核心代码工程结构我按标准三层架构组织controller → service → mapperentity放在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 idselectDeclarationPage resultTypecom.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 teststatus ! null and status ! AND d.status #{status} /if if testkeyword ! 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 classmaterial-hall el-row :gutter20 el-col v-foritem in materialList :keyitem.id :span6 el-card classmaterial-card div classmaterial-name{{ item.name }}/div div classmaterial-info span库存{{ item.stock }} {{ item.unit }}/span span v-ifitem.stock item.safetyStock classdanger-text库存不足/span /div el-input-number v-modelapplyMap[item.id] :min1 :maxitem.stock / el-button typeprimary clicksubmitApply(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(当前状态不可审核); } ListDeclarationItem 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 ListInboundItemVO selectExpiringFirst(Long materialId, Integer needQuantity) { // 按过期时间升序查询有剩余库存的入库批次 return inboundItemMapper.selectAvailableBatches(materialId); }对应的SQL是select idselectAvailableBatches resultTypecom.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只负责把批次按过期时间排序取出来。这也是一个经验复杂的统计逻辑别硬塞给SQLJava层做一遍运算调试时打日志能看得很清楚。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数据库连接串一定要加上serverTimezoneAsia/Shanghai否则Java 8的LocalDateTime类型映射会报错jdbc:mysql://localhost:3306/community_material?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai不加这个参数时在高版本MySQL驱动下会抛出SQLException提示时区无法识别或者你插入的时间比实际时间少8小时。我记得有一次测试时前端显示的申报时间是早上10点数据库里存的是凌晨2点排查半天才发现是服务器默认时区用的UTC。5.3 常见问题速查表现象可能原因解决方案前端请求404后端接口路径与前端api路径不一致检查RequestMapper和axios路径是否少/api前缀登录成功后刷新页面失效token未存储到localStoragelocalStorage.setItem持久化路由守卫里读取库存数量变成负数扣减库存未加库存充足条件UPDATE SQL加 stock #{quantity}申报单审核后明细丢失明细插入与主表未在同一事务提交方法加TransactionalECharts图表不显示日期缺失日期未补0Java层补全日期MapVue列表新增数据不刷新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导入导出功能把出入库单批量导入再比如把审核功能加一个多人审批流。我当时只做了单人审核如果你时间充裕把审批流做成两级审批整个项目的复杂度会立刻上一个台阶放到简历里也更有说服力。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑