最近后台有不少人在问高校物品捐赠管理系统这类项目,说实话它确实是现阶段Java全栈练习里很经典的一个选题。业务边界清晰,但不简单到没东西写;有用户角色、有物品状态流转、有审核流程,这些要素刚好能串起SpringBoot、MyBatis、Vue3这一整套东西。如果你是准备做毕设、课设,或者想找一个完整的前后端分离项目来打通全栈思路,这篇应该能帮上忙。系统本身不复杂,但把技术选型、表结构、业务流程、前端搭建这些全部跑通,收获会非常大。
1. 为什么这个系统值得做:业务模型本身就很典型
先说项目定位。高校物品捐赠管理系统,核心场景是毕业生离校前把带不走的书籍、电器、生活用品发布到平台,在校生根据自己的需要申请领取。听起来就是个二手交易平台,但关键是它比二手交易多了一层"公益属性"和"审核管理"。
这个业务模型的价值在于,它天然覆盖了前后端分离开发中你最需要练的几个点:
- 角色权限:管理员、发布者、申请者,三种角色的操作边界完全不同
- 状态机:物品从草稿到待审核、已发布、已被申请、已领取、已下架,每个状态都有明确流转条件
- 一对多关联:一个用户可以发布多件物品,一个物品可以被多次申请,被打回后重新进入下一个申请周期
- 文件上传:物品照片的存储与展示,涉及静态资源映射
- 统计数据:管理员的顶部看板需要捐赠总数、领取总数、待审核数量之类的统计
这些点凑在一起,正好把Java Web开发的底层能力覆盖了大半。你用SpringBoot实现RESTful接口,用MyBatis完成SQL映射,用Vue3搭管理后台,再配合MySQL存储数据,整套下来,对前后端数据交互、API设计、数据库设计都会有非常直观的认知。
技术栈上我建议用SpringBoot 2.7.x或3.x都行,MyBatis用Spring Boot Starter版本,前端用Vue3 + Vite + Element Plus,数据库MySQL 8.0。这套组合稳定、资料多、社区活跃,遇到问题基本都能搜到解决方案。模板引擎那套老方案(Thymeleaf)就别用了,前后端分离的思路是现在的标配,面试时也更有说服力。
2. 数据模型设计:从物品流转流程逆推表结构
设计数据库之前,最忌讳上来就建表。正确做法是先理清业务流转,再反推需要哪些表和字段。
这个系统的核心流程是这样的:学生注册登录,发布捐赠物品 → 管理员审核通过 → 物品上架展示 → 其他学生浏览并提交申请 → 管理员或发布者处理申请 → 确认领取后物品状态变为已领取 → 记录归档。
2.1 基础用户体系设计
用户表是最基础的,但这里有个容易想歪的地方:要不要搞得很复杂?不需要。高校场景里,用户就是学生和管理员两类,学生用学号注册,管理员后台预设。字段如下:
- id:主键自增
- username:学号/工号,唯一索引
- password:加密存储(BCrypt)
- nickname:昵称
- role:角色标识,0表示学生,1表示管理员
- status:账号状态,0正常,1禁用
- create_time:注册时间
提示:密码加密一定要用BCrypt,不要用MD5或SHA1这类散列算法。BCrypt自带随机盐,即使两个用户密码相同,存储结果也不同,这是安全底线。
外键这块我建议逻辑外键而不是物理外键。也就是说,表关联字段(如user_id)不真的声明FOREIGN KEY约束,而是靠应用层保证一致性。原因很简单:物理外键在高并发写操作时会带来锁竞争,而且MyBatis联表查询时物理外键反而碍事,容易引发不必要的死锁。
2.2 物品信息表:状态字段是关键
物品表是整个系统的核心,需要精心设计:
- id:主键
- user_id:发布者ID,关联用户表
- title:物品名称
- description:物品描述
- category:物品分类(书籍/电器/生活用品/其他)
- images:图片URL,多张图片用逗号分隔
- status:核心状态字段,0待审核,1已上架,2已有人申请,3已领取,4已下架
- views:浏览量
- create_time:发布时间
- update_time:最后更新时间
这里想重点说status字段的设计。很多初学者会把状态做成一张单独的表,记录每个步骤的操作日志,这属于过度设计。在这个项目里,你真正关心的只是当前状态是什么,至于历史状态有没有意义?有,但优先级低。我的做法是:物品表只存当前状态,另外加一张audit_log表存审核记录,这样既保证查询效率,又保留了操作痕迹。
2.3 申请记录与审核日志
申请记录表记录学生对物品的领取申请:
- id
- item_id:物品ID
- user_id:申请者ID
- status:0待处理,1通过,2拒绝
- apply_msg:申请理由/备注
- create_time
- handle_time:处理时间
- handle_user_id:处理人ID
审核日志表记录管理员对物品的状态变更历史:
- id
- item_id
- from_status:原状态
- to_status:新状态
- operator_id:操作人ID
- remark:审核意见
- create_time
这套设计下来,整个系统的数据链路就闭合了。后台可以看到:哪些物品待审核,审核通过率多少,哪些物品申请量大,每个物品的完整生命周期长什么样。与此同时,前台展示也只需要查物品表和用户表两张主表,性能上没有问题。
3. 后端核心实现:SpringBoot分层与业务流程落实
表结构定下来之后,后端代码的组织方式就很清晰了。
3.1 包结构划分:按照职责而非技术类型
常见的错误做法是按技术类型分包:controller包、service包、mapper包,然后所有Controller都扔进去。小项目没问题,项目一大就混乱了。
我更推荐按业务模块分包,比如:
- controller/ItemController、controller/UserController、controller/AdminController
- service/ItemService、service/UserService、service/ClaimService
- mapper/ItemMapper、UserMapper、ClaimMapper
- entity/Item、User、ClaimRecord
- dto/ItemDTO、LoginDTO、ClaimDTO
- common/Result、ExceptionHandler、JwtUtil
这样每个业务模块的代码集中在一起,维护的时候只改对应的包,定位问题非常快。如果以后想拆微服务,按业务模块分包也更方便直接拆出去。
3.2 核心Service:物品发布与状态流转
物品发布流程是这样的:前端传物品表单(标题、描述、分类、图片)→ 后端校验登录状态 → 将状态初始化为0(待审核)→ 插入数据库 → 管理员后台查出待审核列表 → 点通过或驳回。
ServiceImpl里最核心的逻辑是状态机流转。我的实操经验是,不要在各个Controller里到处写if-else判断状态,而是提炼出一个统一的状态更新方法:
public Result updateItemStatus(Long itemId, Integer targetStatus, Long operatorId, String remark) { Item item = itemMapper.selectById(itemId); if (item == null) { return Result.error("物品不存在"); } // 校验当前状态是否能转换到目标状态 if (!ItemStatus.canTransit(item.getStatus(), targetStatus)) { return Result.error("当前状态不支持该操作"); } // 更新物品状态 item.setStatus(targetStatus); item.setUpdateTime(new Date()); itemMapper.updateById(item); // 记录审核日志 AuditLog log = new AuditLog(); log.setItemId(itemId); log.setFromStatus(item.getStatus()); log.setToStatus(targetStatus); log.setOperatorId(operatorId); log.setRemark(remark); auditLogMapper.insert(log); return Result.success("操作成功"); }ItemStatus.canTransit()是一个静态方法,里面放了一张状态流转合法表:
- 0(待审核)可以转1(通过上架)或4(驳回下架)
- 1(已上架)可以转2(有人申请)或4(主动下架)
- 2(申请中)可以转3(已领取)或1(申请超时/拒绝后重新上架)
- 3(已领取)、4(已下架)均为终态
这样做的好处非常明显:非法状态流转在入口处就被拦截,不会出现"已领取的物品还能被申请"这种逻辑漏洞,而且所有状态变更都有日志可查,排错效率高很多。
3.3 申请与领取流程:并发下的正确性
申请环节有一个典型的并发问题:多个用户同时申请最后一件物品,如果不加控制,会出现超领。这里有两种解法:
第一种,在SQL层面做原子更新:
UPDATE item SET status = 2 WHERE id = #{itemId} AND status = 1如果影响行数为1,说明抢锁成功,可以继续写申请记录;如果影响行数为0,说明物品状态已变,本次申请失败。这是基于乐观锁的思路,简单有效。
第二种,用数据库行锁(SELECT FOR UPDATE)包住整个事务。这个方案更重,适合需要读取物品信息后再做复杂判断的场景。对这个项目来说,第一步的原子更新就够了。
我的建议是:申请逻辑一定要放到@Transactional事务方法里,物品状态更新和申请记录插入必须同时成功或同时失败,否则会出现"物品状态变成了申请中,但申请记录没写进去"的脏数据。
@Transactional public Result applyItem(Long itemId, Long userId, String applyMsg) { int rows = itemMapper.updateStatusIfCurrent(itemId, ItemStatus.PUBLISHED, ItemStatus.APPLYING); if (rows == 0) { return Result.error("该物品已被其他同学申请,手慢了"); } ClaimRecord record = new ClaimRecord(); record.setItemId(itemId); record.setUserId(userId); record.setStatus(0); record.setApplyMsg(applyMsg); claimMapper.insert(record); return Result.success("申请提交成功,等待审核"); }这里的updateStatusIfCurrent就是上面那条原子SQL的Mapper方法。注意,递归锁、自旋这些东西在这个场景都用不上,一条SQL就把问题解决了,简单可靠才是第一位的。
3.4 MyBatis实战细节:驼峰映射、联表与TypeHandler
MyBatis这块有几个值得说的点。
第一,map-underscore-to-camel-case这个配置开启后,数据库的create_time字段可以自动映射到Java实体类的createTime属性,省去一长串resultMap。在application.yml里加上:
mybatis: configuration: map-underscore-to-camel-case: true第二,联表查询。物品列表页需要展示发布者的昵称和头像,这时用JOIN:
<select id="selectItemWithUser" resultType="com.example.entity.ItemVO"> SELECT i.*, u.nickname AS publisherName, u.avatar AS publisherAvatar FROM item i LEFT JOIN user u ON i.user_id = u.id <where> <if test="category != null and category != ''"> AND i.category = #{category} </if> <if test="status != null"> AND i.status = #{status} </if> <if test="keyword != null and keyword != ''"> AND (i.title LIKE CONCAT('%', #{keyword}, '%') OR i.description LIKE CONCAT('%', #{keyword}, '%')) </if> </where> ORDER BY i.create_time DESC </select>需要单独建一个ItemVO类,继承Item实体,再补上publisherName和publisherAvatar两个字段。这样前端拿到的数据结构是扁平的,直接渲染,不用在前端再拼一次。
第三,TypeHandler的使用。这个项目中建议躲开一个坑——不要用TypeHandler去处理图片URL列表。图片URL用逗号分隔存字符串,在实体里直接用String接收,前端再做split就够了。TypeHandler的使用场景是Java类型和JDBC类型不一致的时候,比如把LocalDateTime按自定义格式存,或者把枚举存成字符串,这种需求在这个项目里不是必须的。如果你非要练一下TypeHandler,可以把分类category字段定义成枚举(书籍、电器、生活用品),存库时自动映射成代码,读出来时自动映射回枚举,这样代码里不会出现魔法字符串,语义更清晰。
3.5 JWT登录鉴权:换掉老一套的Session方案
前后端分离项目,Session方案有个天然痛点:跨域时Cookie处理麻烦,而且后端集群部署时Session共享是个大坑。所以直接上JWT。
流程很简单:用户登录成功后,后端签发一个JWT,里面包含userId和role,有效期设24小时。前端把Token存在localStorage里,每次请求通过axios拦截器放到Authorization请求头。后端用一个拦截器(HandlerInterceptor)校验Token,解析出用户信息放到ThreadLocal里,供Controller直接获取当前登录用户。
这里要注意的点:
- JWT的secret不要写在代码里,放配置文件,且生产环境要通过环境变量注入
- 拦截器放行登录接口,其他接口全部校验
- ThreadLocal用完必须remove,否则内存泄漏
- 过期时间用短一点的,比如2小时,前端再配合一个刷新机制
代码示例:
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { throw new BusinessException(401, "未登录"); } Claims claims = JwtUtil.parseToken(token.substring(7)); if (claims == null) { throw new BusinessException(401, "登录已过期"); } UserContext.set(claims); return true; } }4. 前端实现:Vue3后台管理界面的搭建思路
前端这一块,Vite + Vue3 + Element Plus是当前推荐组合。Vite的冷启动速度比Webpack快一个量级,开发体验提升非常明显。
4.1 前端工程结构设计
我的建议是按页面维度组织:
- src/api/:所有后端接口的封装,按业务模块拆文件,如item.js、user.js、claim.js
- src/router/:路由配置,做权限控制
- src/views/:页面组件,比如管理员端的ItemManage.vue、UserManage.vue、Dashboard.vue
- src/components/:公共组件,比如状态标签组件、图片上传组件
- src/utils/:Axios实例、token管理、工具函数
4.2 Axios封装:统一处理Token与错误拦截
前后端分离项目,API层的封装是最容易偷懒但也最值得做好的部分。核心思路是创建一个专门的axios实例,然后通过两个拦截器统一处理请求和响应。
请求拦截器做的事很固定:从localStorage里取token,加到请求头里。
request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config })响应拦截器做的事稍微多一点:剥离后端的统一包装结构,拿到真实的业务数据;遇到401状态码,清除本地token并跳转登录页;遇到其他业务错误码,用Element Plus的Message组件统一弹提示。这样每个页面调用接口时,只需要关心成功分支,错误分支全被拦截器吞掉了,页面代码会干净很多。
4.3 权限控制:根据角色渲染菜单和路由
Vue3的权限控制建议用动态路由的方式。具体做法是:登录成功后,后端返回当前用户的角色,前端根据角色过滤出可访问的路由,然后用router.addRoute()动态注册。
比如管理员可以看到物品管理、用户管理、数据统计三个菜单,学生登录后只看到捐赠大厅和我的申请。菜单用数组配置驱动渲染,每个菜单项关联路由名称和图标,这样增减菜单只需要改配置数组,不用改模板。
4.4 核心页面实现:物品发布表单与审核列表
物品发布表单有一个细节值得专门说:图片上传。Element Plus的upload组件默认是手动上传到后端接口,拿到返回的URL后再随表单一起提交。这就涉及一个问题——如果用户填写表单填到一半,刷新了页面,图片已经传到服务器了,但物品还没创建,就会产生孤儿图片。
更好的做法是:图片上传成功后将URL暂存到本地状态,表单提交时把URL数组一并提交;同时提供一个清空重传的按钮。对于这个项目的体量,最多加一个创建时间超过1小时且未被物品引用的图片定时清理即可,不用做什么复杂对象存储。
审核列表页的逻辑比较直观:管理员进入待审核列表,看到物品卡片,上面显示发布时间、发布者、分类,点开能看到详细描述和图片,然后点【通过】或【驳回】。驳回时弹一个输入框填写原因,这个原因会存到审核日志里,同时通过WebSocket或通知表推送给学生端。如果不想引入实时推送,最简单的方式就是学生端在进入页面时重新拉取一次自己的物品列表状态,看有没有变化,下拉刷新即可。
5. 环境准备与联调排坑:实测中踩过的典型问题
这个项目在从零搭建到跑通的整个过程中,有几个问题是大家几乎必踩的,我单独列出来。
5.1 MySQL 8连接报错:SSL与时区问题
如果你用MySQL 8.0 + 新版JDBC驱动,经常会在启动SpringBoot时报这样的错误:The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized,或者SSL连接相关错误。
解决办法是在数据库连接URL上加两个参数:
jdbc:mysql://localhost:3306/donation?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4useSSL=false在本地开发阶段直接关掉加密层,省去证书配来配去的麻烦。serverTimezone指定时区,避免MySQL默认的CST时区被JDBC解析成乱码。这两个参数是初学者在这里卡时间最长的点,留意加好就能避开。
5.2 跨域问题:前后端分离的第一道坎
Vue3开发服务跑在5173端口(Vite默认),SpringBoot跑在8080端口,浏览器禁止页面直接向不同端口发请求,就会出现CORS错误。
后端全局配置CorsFilter是最省事的方案:
@Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("http://localhost:*"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); }注意生产环境要收窄
addAllowedOriginPattern,只允许自己的域名或者Nginx代理地址,否则等于把接口裸奔在外面,任何人跨域都能调你的API。
还有一个小细节:使用了自定义JWT拦截器后,OPTIONS预检请求也要放行,否则前端请求会被拦截器挡在门外。在JwtInterceptor的preHandle里加一个判断就行:
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; }5.3 端口冲突与被占用的排查
Vite默认5173,SpringBoot默认8080,MySQL默认3306。如果你之前装过一些中间件,很容易出现8080被占用的情况。最简单的排查方式:
netstat -ano | findstr 8080然后根据PID去任务管理器把对应进程结束掉。如果不想结束,也可以在application.yml里换端口:
server: port: 8081前后端联调时注意前端请求的baseURL要和你后端的实际端口保持一致,这是初学者经常忽略的细节。Vite的代理(proxy)配置里,把/api开头的请求都转发到后端地址,前端代码里写相对路径即可,这样连跨域配置都可以省掉,部署时代理层直接用Nginx做,更合理。
5.4 MyBatis中常见的映射错误
第一个高频错误:实体类字段和表字段大小写对不上。比如数据库字段叫item_id,Java实体属性叫itemId,如果忘了开启驼峰映射,查出来的item_id字段赋值给null,页面上的ID就全是空白。
第二个高频错误:<if test="category != null and category != ''">这类动态SQL里,比较字符串常量要用单引号包住,但XML里可能被外部单引号干扰,实际使用中如果发现条件始终不生效,检查一下是不是双引号转义出了问题。
第三个高频错误:resultType和resultMap混用。简单查询用resultType,但遇到select *和特殊字段(比如枚举、金额)时就要考虑resultMap。如果你开启了驼峰映射,大部分场景resultType都能搞定,不需要额外写resultMap,切记不要为了炫技而炫技。
6. 部署上线与其他值得补充的能力
项目开发完,部署和上线是另一个大坑,尤其是没有系统学过运维的人,这里讲讲最常见的两种做法。
6.1 前端打包 + Nginx静态托管方案
前端在Vite项目根目录执行:
npm run build会生成dist目录。把dist目录扔到Nginx的html目录下,同时配置一个反向代理,把/api开头的请求转发给后端的SpringBoot服务:
server { listen 80; server_name your-domain.com; root /usr/share/nginx/html; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }最后一行非常重要。Vue是单页应用,前端路由由JS接管,如果用户直接在浏览器地址栏输入https://your-domain.com/item/1,Nginx会先去磁盘找item/1这个路径,找不到就会404。加了try_files $uri $uri/ /index.html之后,所有找不到的路径都会退回到index.html,交给Vue Router处理,配合后端接口返回数据,页面就能正常渲染。
6.2 SpringBoot打包与启动运维
后端打包:
mvn clean package -DskipTests生成jar包后直接后台启动:
nohup java -jar donation-system.jar --server.port=8080 --spring.profiles.active=prod > /var/log/donation.log 2>&1 &如果服务器内存有限,用Docker会稍微好管理一点。Dockerfile可以这么写:
FROM openjdk:17-jdk-slim COPY target/donation-system.jar /app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "/app.jar"]然后在服务器上执行docker build -t donation-server . && docker run -d -p 8080:8080 -e DB_HOST=... --name donation donation-server。生产环境建议数据库连接信息通过环境变量传入容器,不要在jar包里的配置文件写死真实的数据库密码和IP。
6.3 后续扩展:让项目从作业变成作品
基础系统跑通后,我建议无论你是做毕设还是自用,都加上下面这三个能力中的至少一个,项目含金量会提升一个层次:
管理员仪表盘:统计卡片加趋势图。统计物品总数、捐赠申请总数、待审核数、领取完成数,用ECharts画折线图看近一周的活跃趋势。这些数据都从现有表结构里查出来就行,不需要引入额外技术栈。
消息通知:当学生的物品申请状态变化时,登录后能看到系统消息。最简单做法是建一张notification表(id, user_id, content, read_status, create_time),申请结果操作时插入一条记录,前端在顶部导航上加一个小铃铛,轮询或进入页面时拉取未读数量。
多条件筛选与搜索:后端ItemMapper的联表查询已支持分类、状态、关键词三个条件,前端把筛选条件做成Element Plus的Select组件,选中后重新请求列表,属于非常轻的扩展,但用户体感上会感觉系统"很完整"。
7. 项目结构复盘与给自己的学习建议
整套系统做完之后,我建议你花时间做一次垂直复盘,不要急着开始下一个项目。你可以带着这几个问题回看自己的代码:
- 物品从发布到领取的完整生命周期里,状态字段有没有在任何环节出现"卡死"或"跳变"的可能?如果没有,靠的是什么机制;如果有,应该在哪一层修复?
- 如果把某一个Service接口的并发量从1QPS提升到100QPS,现在数据库的表结构和SQL写法是否有明显瓶颈?如果有,应该先改哪里?
- 你自己写的代码里,有多少是真正的业务逻辑,有多少是在处理参数校验、异常捕获、字段映射这些样板代码?如果样板代码占比过高,说明DTO、VO的使用还不够充分。
这些区域想清楚之后,你对SpringBoot + MyBatis + Vue3这套组合的理解深度,就已经超过单纯跑通CRUD和复刻一个Demo的层面了。系统本身不难,难的是在实现的过程中建立起"状态、权限、事务、异常"这四件事的整体直觉。有了这个基础,后面学微服务也好,换数据库也好,遇到的坑都能追溯到今天这段根因上。
如果遇到"已发布的物品,管理员删了物品但申请单没清理"这类数据一致性问题,优先检查事务边界是不是放错了位置,这是面试和实际工作中都会被问到的高频场景。