
最近后台有不少人在问高校物品捐赠管理系统这类项目说实话它确实是现阶段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加密存储BCryptnickname昵称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 申请记录与审核日志申请记录表记录学生对物品的领取申请iditem_id物品IDuser_id申请者IDstatus0待处理1通过2拒绝apply_msg申请理由/备注create_timehandle_time处理时间handle_user_id处理人ID审核日志表记录管理员对物品的状态变更历史iditem_idfrom_status原状态to_status新状态operator_id操作人IDremark审核意见create_time这套设计下来整个系统的数据链路就闭合了。后台可以看到哪些物品待审核审核通过率多少哪些物品申请量大每个物品的完整生命周期长什么样。与此同时前台展示也只需要查物品表和用户表两张主表性能上没有问题。3. 后端核心实现SpringBoot分层与业务流程落实表结构定下来之后后端代码的组织方式就很清晰了。3.1 包结构划分按照职责而非技术类型常见的错误做法是按技术类型分包controller包、service包、mapper包然后所有Controller都扔进去。小项目没问题项目一大就混乱了。我更推荐按业务模块分包比如controller/ItemController、controller/UserController、controller/AdminControllerservice/ItemService、service/UserService、service/ClaimServicemapper/ItemMapper、UserMapper、ClaimMapperentity/Item、User、ClaimRecorddto/ItemDTO、LoginDTO、ClaimDTOcommon/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实战细节驼峰映射、联表与TypeHandlerMyBatis这块有几个值得说的点。第一map-underscore-to-camel-case这个配置开启后数据库的create_time字段可以自动映射到Java实体类的createTime属性省去一长串resultMap。在application.yml里加上mybatis: configuration: map-underscore-to-camel-case: true第二联表查询。物品列表页需要展示发布者的昵称和头像这时用JOINselect idselectItemWithUser resultTypecom.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 testcategory ! null and category ! AND i.category #{category} /if if teststatus ! null AND i.status #{status} /if if testkeyword ! 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.jssrc/router/路由配置做权限控制src/views/页面组件比如管理员端的ItemManage.vue、UserManage.vue、Dashboard.vuesrc/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?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8mb4useSSLfalse在本地开发阶段直接关掉加密层省去证书配来配去的麻烦。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默认5173SpringBoot默认8080MySQL默认3306。如果你之前装过一些中间件很容易出现8080被占用的情况。最简单的排查方式netstat -ano | findstr 8080然后根据PID去任务管理器把对应进程结束掉。如果不想结束也可以在application.yml里换端口server: port: 8081前后端联调时注意前端请求的baseURL要和你后端的实际端口保持一致这是初学者经常忽略的细节。Vite的代理proxy配置里把/api开头的请求都转发到后端地址前端代码里写相对路径即可这样连跨域配置都可以省掉部署时代理层直接用Nginx做更合理。5.4 MyBatis中常见的映射错误第一个高频错误实体类字段和表字段大小写对不上。比如数据库字段叫item_idJava实体属性叫itemId如果忘了开启驼峰映射查出来的item_id字段赋值给null页面上的ID就全是空白。第二个高频错误if testcategory ! 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/1Nginx会先去磁盘找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.port8080 --spring.profiles.activeprod /var/log/donation.log 21 如果服务器内存有限用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的层面了。系统本身不难难的是在实现的过程中建立起状态、权限、事务、异常这四件事的整体直觉。有了这个基础后面学微服务也好换数据库也好遇到的坑都能追溯到今天这段根因上。如果遇到已发布的物品管理员删了物品但申请单没清理这类数据一致性问题优先检查事务边界是不是放错了位置这是面试和实际工作中都会被问到的高频场景。