简介:面向高校毕业设计或课程项目的开发者,这份资源提供一套前后端分离的校园失物招领系统完整实现。后端基于Java与Spring Boot,前端采用Vue,附带MySQL数据库脚本,覆盖失物发布、招领信息登记、预约领取、用户管理等核心功能,结构清晰,适合学习典型业务流。整个压缩包共1816个文件,包含java后端源码、vue前端组件、静态资源、配置文档、数据库脚本等,类型涉及js、html、css、xml、图片与音视频素材,压缩包约61.59MB,目录划分明确,便于直接导入开发工具运行或二次开发。当前已有2761人下载学习,说明该项目具备较高的参考热度。配套功能介绍文档详细说明了模块划分与操作步骤,可帮助使用者快速理解系统运行逻辑。项目经严格调试确保稳定运行,能够支撑毕业设计答辩,也适合作为Spring Boot与Vue前后端分离项目的练手素材。
1. 校园失物招领系统:Springboot+Vue复现前先看这三个设计
一个springboot+vue的失物招领系统,属于看着简单、实则链条很长的选题。表面是失物登记、挂失、认领、后台管理这几个页面,真正落地时你要处理的是登录状态、图片上传、状态流转、前后端字段对齐,这些恰好是前后端分离项目里最常翻车的地方。这套资源能让你在本地把一套完整的失物招领系统跑通,前端Vue负责页面和交互,后端Springboot提供接口和事务,适合正在做课程设计、或者想拿一个全栈项目练手的开发者。打开压缩包之前,建议先想清楚三件事:表结构里状态字段怎么设计、后端接口怎么分层、前端请求拦截器放在哪。把这三件事理顺,剩下的都是沿流程往下写的代码。
2. 表结构设计:失物、认领、用户的状态字段和流转关系怎么落地
我拿到这类项目源码,一般会跳过 README,直接翻数据库脚本和实体类。失物招领系统的复杂度不在页面,而在“状态流转”和“数据关联”这两件事。一条失物记录从登记到匹配到认领完成,状态字段设计得好,后面接口写起来很顺;设计得随意,改一次状态就要牵动前后端好几处代码。
2.1 四张核心表的外键关系与字段约定
这个项目的数据模型是四张表:用户表、失物表、认领表、留言表。用户表管登录和角色,失物表管登记信息,认领表管申请记录,留言表用来做补充沟通,比如拾取者和失主互相确认“你的包是什么颜色”“学生证上有没有名字”。四张表的关系不复杂,但认领表承接了两侧的用户操作,需要单独设计。
失物表是核心,字段基本决定了整个项目的展示能力:
CREATE TABLE `lost_item` ( `id` BIGINT AUTO_INCREMENT PRIMARY KEY, `title` VARCHAR(100) NOT NULL COMMENT '标题,比如:蓝色保温杯', `description` TEXT COMMENT '详细描述,补充颜色、型号、特征', `location` VARCHAR(200) COMMENT '丢失或拾取地点', `contact` VARCHAR(50) COMMENT '联系方式', `image_url` VARCHAR(255) COMMENT '图片访问路径', `type` TINYINT NOT NULL COMMENT '0=丢失 1=拾取', `status` TINYINT DEFAULT 0 COMMENT '0=待匹配 1=已认领 2=已关闭', `user_id` BIGINT NOT NULL COMMENT '发布人ID', `create_time` DATETIME COMMENT '发布时间', `update_time` DATETIME COMMENT '更新时间', KEY `idx_user_id` (`user_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='失物招领信息表';这里我把 status 和 type 都设计成 TINYINT 数字而不是字符串,这是刻意为之。数字在索引上更省空间,查询条件写 0/1 比写 'pending' 更干净;前端下拉框的枚举文案可以随时调整,不依赖数据库里的字符串内容。location 字段只存文字地点,不存经纬度,因为校园失物的匹配是靠人工判断,不是 LBS 定位,加地图反而增加前端复杂度。
认领表是另一种思路,它只存关联关系,不冗余物品信息:
CREATE TABLE `claim` ( `id` BIGINT AUTO_INCREMENT PRIMARY KEY, `lost_item_id` BIGINT NOT NULL COMMENT '关联失物ID', `user_id` BIGINT NOT NULL COMMENT '发起认领的用户ID', `reason` VARCHAR(500) COMMENT '认领理由,比如:这是我的包,里面有一张校园卡', `status` TINYINT DEFAULT 0 COMMENT '0=待审核 1=通过 2=拒绝', `create_time` DATETIME, KEY `idx_lost_item` (`lost_item_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='认领申请表';认领表里没有 title、没有 image_url,只存 lost_item_id。查询时 join 失物表拿详情,认领表只负责表达“谁在什么时间想认领哪条记录”。这么做有一个实际好处:失物状态变成“已认领”之后,历史认领记录不会因为失物信息被更新而丢失,管理员审核时的操作记录也能完整保留。
用户表在这个项目里字段更少:id、username、password、phone、role。其中 role 用 int 区分,0 是普通用户,1 是管理员。密码字段要存 BCrypt 加密后的结果,不能用明文。管理员和普通用户的接口权限差异,在前端通过路由守卫控制,在后端通过拦截器处理,两层都做才安全。
2.2 状态流转:谁在什么时候把 status 从 0 改成 1
这个项目里最容易讲不清楚的就是状态流转。我理一下主流程:用户 A 发布一条丢失物品记录,status 初始为 0;用户 B 看到后发起认领,生成一条 claim 记录,claim.status 也为 0;管理员在后台看到认领申请,审核通过就把 claim.status 改成 1,同时把 lost_item.status 改成 1,表示这条失物已经匹配完成。
这个流转里有个关键约束:修改 lost_item.status 和修改 claim.status 必须放在同一个事务里。代码层面是这样体现的:
@Transactional(rollbackFor = Exception.class) public R auditClaim(Long claimId, Integer result) { Claim claim = claimMapper.selectById(claimId); if (claim == null) { return R.error("认领记录不存在"); } claim.setStatus(result); claimMapper.updateById(claim); if (result == 1) { LostItem item = lostItemMapper.selectById(claim.getLostItemId()); item.setStatus(1); lostItemMapper.updateById(item); } return R.ok(result == 1 ? "认领通过" : "认领拒绝"); }标注 @Transactional 后,audit 方法里两步写操作会被事务包裹。如果只更新 claim 表,更新 lost_item 表时报错,数据就会不一致。我第一次复现这个项目时偷懒,单独写了 updateClaim 和 updateLostItem 两个方法在前端依次调用,结果第二次网络超时,页面上认领状态显示通过,物品状态还在待匹配,只能手动改数据库补救。
状态枚举建议在项目里集中定义一份常量类,后端和前端共用同一套值。这个项目常见的约定如下:
| 字段 | 值 | 含义 |
|---|---|---|
| lost_item.status | 0 | 待匹配 |
| lost_item.status | 1 | 已认领 |
| lost_item.status | 2 | 已关闭 |
| claim.status | 0 | 待审核 |
| claim.status | 1 | 已通过 |
| claim.status | 2 | 已拒绝 |
| user.role | 0 | 普通用户 |
| user.role | 1 | 管理员 |
把状态枚举固定下来之后,前端下拉框、列表筛选、后端统计逻辑都基于这套数字来做,而不是在代码里到处写魔法字符串。这属于前期多花十分钟、后期少踩几个雷的设计。
3. 后端Springboot接口实现:三层结构、事务与分页参数怎么对齐
表结构定了,接口基本能顺着推出来。这个项目的接口可以分成三组:失物发布与查询、认领申请与审核、登录与用户管理。后端这块我建议严格执行三层结构,Controller 只接收参数和返回结果,Service 做业务和事务,Mapper 负责 SQL。
3.1 Controller 与 Service 的职责边界
Controller 层最容易犯的错是把业务判断写进 Controller。比如在 Controller 里先查一遍用户是否存在、再判断权限、再调用 service,最后代码越写越膨胀。这个项目里 Service 层的写法更合适:
@RestController @RequestMapping("/api/lost") public class LostItemController { @Resource private LostItemService lostItemService; @PostMapping("/publish") public R publish(@RequestBody LostItemVO vo, @RequestAttribute Long userId) { return lostItemService.publish(vo, userId); } }这里有个参数传递的细节:userId 不是前端传的,而是后端拦截器从 token 里解析后塞进 request attribute,Controller 直接用 @RequestAttribute 拿。这样做的好处是前端无法伪造 userId,发布、认领、审核这些敏感操作都从会话身份里取当前用户,而不是相信请求体里的字段。
Service 实现里除了 publish、auditClaim,还有一个容易被忽略的方法——分页查询。失物列表页是前端访问量最大的接口,分页参数要对齐:
public Page<LostItemVO> pageList(Integer pageNum, Integer pageSize, String keyword, Integer type) { Page<LostItem> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<LostItem> wrapper = new LambdaQueryWrapper<>(); if (keyword != null && !keyword.isEmpty()) { wrapper.and(w -> w.like(LostItem::getTitle, keyword) .or().like(LostItem::getDescription, keyword)); } if (type != null) { wrapper.eq(LostItem::getType, type); } wrapper.orderByDesc(LostItem::getCreateTime); Page<LostItem> result = lostItemMapper.selectPage(page, wrapper); return convertToVO(result); }关键字搜索这里有个非常容易翻车的细节:如果写成 wrapper.like(...).or().like(...),MyBatis-Plus 会生成不带括号的 SQL,条件变成 “title like ? or description like ? and type = ?”,优先级错乱,type 过滤就失效了。外面套一层 wrapper.and(w -> ...) 之后,搜索条件和 type 条件之间才形成正确的括号关系。这种细节不实测一次根本发现不了,我在这里翻过车,印象很深。
3.2 统一返回体和全局异常处理
前后端联调时,接口返回结构不统一是最浪费时间的破事。这个项目里我习惯把所有接口返回统一封装成一个 R 对象,包含 code、message、data 三个字段,code 用 200 表示业务成功,500 表示业务失败,401 表示未登录。
@Data public class R { private Integer code; private String message; private Object data; public static R ok(String message) { R r = new R(); r.code = 200; r.message = message; return r; } public static R error(String message) { R r = new R(); r.code = 500; r.message = message; return r; } }这里要注意,HTTP 状态码和业务 code 是两回事。前端 axios 拦截器里判断的是业务 code,HTTP 200 只代表请求到达了后端。如果把业务错误直接返回 HTTP 500,axios 的 error 分支会把异常错误提示弹出来,用户看到的内容很不友好。用统一的 R 对象后,前端只需要关心 code 是不是 200。
3.3 application.yml 配置与定时清理任务的扩展点
后端配置里有两个地方值得留意。第一个是 Jackson 时间格式,第二个是文件上传路径:
spring: datasource: url: jdbc:mysql://localhost:3306/lost_found?useUnicode=true&characterEncoding=utf8 username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 servlet: multipart: max-file-size: 10MB max-request-size: 20MBdate-format 不配置的话,Date 类型传给前端会变成时间戳数字,页面显示一串 14 位数字,极其影响体验。time-zone 必须设置 GMT+8,否则服务器时区不对,前端看到的时间会差 8 个小时。这两个配置属于看着不起眼、不配就要出幺蛾子的典型。
失物招领系统还有一个自然扩展点:超过 30 天仍处于“待匹配”状态的记录,应该自动关闭。这个东西用 Spring Boot 的定时任务做非常顺手:
@Component public class LostItemScheduler { @Resource private LostItemMapper lostItemMapper; @Scheduled(cron = "0 0 2 * * ?") public void closeExpiredItems() { LambdaUpdateWrapper<LostItem> wrapper = new LambdaUpdateWrapper<>(); wrapper.eq(LostItem::getStatus, 0) .lt(LostItem::getCreateTime, LocalDateTime.now().minusDays(30)) .set(LostItem::getStatus, 2); lostItemMapper.update(null, wrapper); } }注意启动类上要加 @EnableScheduling 注解,否则定时任务不会生效。这个清理逻辑不复杂,但它验证了“失物招领系统不止 CRUD”这件事,面试或者答辩时可以作为一个亮点聊。
4. 前端Vue页面与接口联调:路由守卫、axios封装和上传组件
前端这块,我建议从三个部分去理解:路由怎么组织、请求怎么封装、页面组件怎么和后端字段对齐。这三个部分对应着用户从打开页面到完成操作的完整链路。
4.1 路由设计与登录守卫
项目里的页面大概有这些:列表页、失物详情页、发布页、我的认领页、后台管理页。路由设计时我会按功能模块拆分,把懒加载写上:
// src/router/index.js import Vue from 'vue' import Router from 'vue-router' Vue.use(Router) const router = new Router({ routes: [ { path: '/', name: 'Home', component: () => import('@/views/Home.vue') }, { path: '/lost/detail/:id', name: 'LostDetail', component: () => import('@/views/LostDetail.vue') }, { path: '/lost/publish', name: 'LostPublish', component: () => import('@/views/LostPublish.vue'), meta: { requiresAuth: true } }, { path: '/my/claims', name: 'MyClaims', component: () => import('@/views/MyClaims.vue'), meta: { requiresAuth: true } }, { path: '/admin', name: 'Admin', component: () => import('@/views/Admin.vue'), meta: { requiresAuth: true, requiresAdmin: true } } ] }) router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.matched.some(record => record.meta.requiresAuth) && !token) { next({ path: '/login', query: { redirect: to.fullPath } }) } else { next() } }) export default router路由守卫的判断基于 localStorage 里有没有 token。需要注意,这个设计只做了前端拦截,安全边界在后端的鉴权拦截器,前端守卫只是提升用户体验,两者不能互相替代。requiresAdmin 这个字段在示例代码里没有完整实现,如果后台管理页要严格限制管理员访问,需要在守卫里额外调用一次用户信息接口判断角色。
4.2 axios 封装与拦截器
前后端联调最繁琐的部分是每个请求都要处理 token 和错误提示。axios 封装的价值在于把这件事收敛到一处:
// src/utils/request.js import axios from 'axios' import { Message } from 'element-ui' import router from '@/router' const service = axios.create({ baseURL: process.env.VUE_APP_BASE_URL || 'http://localhost:8080', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = token } return config }, error => Promise.reject(error)) service.interceptors.response.use( response => { const res = response.data if (res.code === 200) { return res } Message.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push({ path: '/login' }) } else if (error.response && error.response.status === 500) { Message.error('服务器内部错误') } return Promise.reject(error) } ) export default service这段拦截器做了三件事:请求前从 localStorage 取 token 并放到 Authorization 头;响应时先看业务 code,不是 200 就弹错误;遇到 HTTP 401 时清掉 token 并跳登录页。注意 timeout 设置的是 10 秒,如果上传图片比较大,单独给上传请求设置更长的 timeout,或者直接跳过这个实例。
4.3 上传组件的 headers 与图片回显
发布失物时图片上传用 Element UI 的 el-upload。这里有一个经典的坑:el-upload 默认不带自定义 header,token 不会自动附带,上传接口会报 401。解决办法是给组件绑定一个 headers 对象:
<el-upload action="/api/upload" :headers="uploadHeaders" name="file" :on-success="handleUploadSuccess" :limit="4"> <el-button size="small">上传图片</el-button> </el-upload>uploadHeaders 在 data 里返回{ Authorization: localStorage.getItem('token') }。这里还需要注意 on-success 回调里拿到的是后端响应,实际使用时要把返回的图片路径存到表单字段里,不能直接把上传组件的值当最终数据提交。
图片回显的规则也要前后端对齐。如果后端返回的是相对路径/uploads/2024/11/abc.jpg,前端 img 标签不能直接用,要拼上当前域名:
const fullUrl = document.location.origin + imageUrl但如果你在后端配置了静态资源映射,并且前端页面和后端接口同源,这个拼接就不需要。开发环境里前后端端口不同,这一步很容易踩坑,稍后避坑章节会详细展开。
5. 避坑排查:Springboot+Vue联调最容易翻车的四个现场
这个项目我在本地完整跑通花了一个晚上,期间遇到的阻碍九成不是功能代码问题,而是环境和联调细节。下面四个现场是我实际遇到的,按“现象、原因、解决”列出来,你在复现时如果遇到同样的报错,可以直接照着排查。
5.1 现象一:登录接口被浏览器拦截,控制台报 CORS 错误
现象:前端登录页输入账号密码,点击登录后页面没有跳转,打开浏览器控制台看到 “Access to XMLHttpRequest at ... has been blocked by CORS policy” 的红色报错。
原因:前端开发服务器运行在 9528 端口,Spring Boot 运行在 8080 端口,端口不同就构成了跨域。浏览器默认拦截跨域响应,即使后端接口真实返回了数据,前端也读不到。
解决:后端加一个跨域配置类,允许开发环境的指定源访问:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("http://localhost:9528") .allowedMethods("*") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }allowedOrigins 这里不要写*,因为 allowCredentials(true) 不允许通配源。如果项目用了 Spring Security,还需要在 Security 配置里放行这个 CORS 配置,否则会被过滤链拦截在更早的位置。生产环境如果前后端同源部署,这一段可以直接去掉。
5.2 现象二:图片上传成功,但详情页回显 404
现象:upload 接口返回成功,数据库里存了/uploads/2024/11/xxx.jpg,前端 img 标签 src 直接填这个路径,浏览器请求这个地址却返回 404。
原因:Spring Boot 默认只映射 classpath 下的 static 资源目录,磁盘上的 uploads 文件夹不在映射范围内。文件上传时写入了磁盘,但访问路径找不到对应处理器。
解决:配置资源映射,把 /uploads/** 指向磁盘目录:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/uploads/**") .addResourceLocations("file:D:/uploads/"); } }注意 addResourceLocations 的写法,Windows 下是file:D:/uploads/,结尾斜杠不能丢;Linux 下是file:/home/app/upload/。路径写错最常见的现象就是配置写了但没生效。另外上传目录要在服务器上提前建好并授权,否则运行时才报 FileNotFoundException。
5.3 现象三:页面显示 createTime 是一串时间戳数字
现象:失物列表渲染后,创建时间列显示的是1733006800000这种数字,怎么格式化都无效。
原因:Spring Boot 默认用 Jackson 序列化 java.util.Date,输出的是自 1970 年以来的毫秒数。前端拿到的是 Number 类型,直接渲染就成了时间戳。
解决:在 application.yml 里配置时间格式:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8如果实体里用的是 LocalDateTime 而不是 Date,需要额外引入 jsr310 模块,配置项改成:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 serialization: write-dates-as-timestamps: false改完后端配置后记得重启。前端如果还需要格式化,可以用 dayjs 插件,但后端把格式统一是最省事的方式。
5.4 现象四:IDEA 里后端前端同时跑,端口互相占用
现象:在 IDEA 里先启动 Spring Boot 占用 8080,再执行 npm run dev 启动 Vue 项目,提示端口被占用,或者 Vue 项目启动后访问空白。
原因:Vue CLI 项目默认 dev server 端口也是 8080,和后端冲突。很多课程设计项目都是用 Vue CLI 创建的,这一冲突几乎必现。
解决:给 vue.config.js 配置独立端口和代理:
module.exports = { devServer: { port: 9528, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } }配置代理之后,前端请求/api/login会被转发到http://localhost:8080/api/login,浏览器里不存在跨域问题,5.1 里的后端正则配置也可以省略。开发环境推荐用这种方式,生产环境建议直接打包合并部署,下一章具体讲。
6. 部署进阶与验证:vue打包放进springboot的路径细节
前后端分离项目最终部署时,最省事的方案不是开两个端口各自跑,而是把 Vue 构建产物直接放进 Spring Boot 的静态资源目录,打成一个 jar 包。这里我用的是手动合并的方式:在 Vue 项目根目录执行 npm run build,产物生成到 dist 文件夹;复制 dist 下的所有文件到后端工程的 src/main/resources/static 目录;重新启动 Spring Boot,访问 http://localhost:8080 就能看到完整页面。
有个前置问题必须处理:Vue 打包后的静态资源默认使用绝对路径引用,比如/js/app.js,部署到 Spring Boot 后会从根目录找这个文件,而它实际在 static/js/app.js,路径对不上就会出现空白页。解决方法是修改 vue.config.js:
module.exports = { publicPath: './' }改成相对路径后,打包产物里的引用会变成./js/app.js,部署到任意子路径都不怕。每次重新构建前端都要重新复制一次 dist 目录,这个流程比较繁琐,但作为理解“前后端如何合并”的第一步非常直观。
验证部署是否成功,先用 curl 测接口连通性,再测页面:
curl -X POST http://localhost:8080/api/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"123456"}'返回 JSON 里 code 为 200,说明后端接口正常。接下来打开首页,找一条带图片的失物记录,确认图片能正常加载。如果图片加载不出来,优先检查上传目录在 jar 部署环境里是否存在于可写的位置,推荐通过 application.yml 配置 upload.dir 指向当前运行目录下的子文件夹,而不是写死成 D:/uploads。
从那以后,我每次拿到这类基于 springboot+vue 的项目,都会先强制走一遍“后端启动 → 前端启动 → 登录 → 发布 → 认领 → 管理员审核”的完整链路,再去看源码细节。越是在看起来简单的业务上,流程验证越不能跳过,这套习惯让我少踩了很多隐蔽的坑,希望帮到你。
本文还有配套的精品资源,点击获取