我们学校"失物招领处"的真实状态是:办公室里堆了一箱学生卡,墙上贴着两年前的旧通知,几乎没人会主动去看。同学丢了东西的第一反应是发朋友圈和年级群,效果完全看运气。捡到东西的人往往是群里问一圈没人认领,最后顺手放回原处,信息就这么断了。
所以当我决定做这个校园失物招领系统时,目标很明确:不做一个纯 CRUD 练手项目,而是把"发布-搜索-认领"这个闭环完整搬到线上,让丢失物品能快速曝光,让失主能被方便地找到。整个项目采用 SpringBoot + Vue + MyBatis + MySQL 搭建,前后端分离架构,前端独立构建部署,后端只提供 RESTful 接口,整体包含完整源码和部署教程。如果你正在做课程设计、毕业设计,或者想完整走一遍前后端分离项目的开发全流程,这篇文章可以直接照着做。
这个系统的核心就是两条链路:
- 丢东西的人:发布寻物信息 → 等待拾到者联系 → 核对确认 → 结案。
- 捡到东西的人:发布招领信息 → 失主搜索到 → 核对细节 → 认领结案。
后面所有表结构、接口、页面设计,都是围绕这两条链路展开的。下面我把整个项目的思考过程、代码结构和部署步骤完整复盘一遍。
1. 校园失物招领这个场景,先把需求拆明白
1.1 线下失物招领的痛点到底是什么
很多人以为失物招领系统的核心是"记一笔谁丢了什么、谁捡到了什么",其实不是。真正的问题是信息触达效率太低。
线下场景里,丢东西的人只能去失物招领处碰运气,捡到东西的人只能把物品放在某个固定地点等失主来找,两边完全没有实时匹配的能力。公告栏里的 A4 纸没人细看,年级群的消息很快被刷上去,学生卡、U盘、充电宝这类小物件,往往要在柜子里躺一学期。
再深一层,校园失物招领还有一个特殊点:高价值物品少,低价值物品多,但失主对物品的情感依赖很强。学生丢的大多是"不算贵但必须用"的东西,比如学生卡、身份证、银行卡、教材、耳机。这类物品最重要的不是价值补偿,而是尽快回到主人手里,所以系统必须支持快速发布、快速搜索、快速联系。这决定了功能设计要轻、操作路径要短。
1.2 三种角色与功能边界
我按实际使用场景把系统用户分成三类,功能也按角色收敛:
| 角色 | 核心诉求 | 功能范围 |
|---|---|---|
| 普通用户(学生/教职工) | 找东西、找失主 | 注册登录、发布寻物/招领、浏览搜索、提交认领申请、管理自己的发布记录 |
| 管理员 | 保证信息真实、处理纠纷 | 审核内容、下架违规或已过期信息、重置违规用户、查看全站数据 |
| 游客(可选) | 先看看有没有自己的东西 | 只开放浏览和搜索,但发布、认领必须登录 |
这里有一个设计决策值得说明:认领流程我选择"先申请,后确认",而不是让双方直接交换联系方式。
原因是失物招领天然存在冒领风险。如果系统直接把拾到者的手机号全量展示给所有人,那任何人都可以打电话说"这东西是我的",失主权益就没法保障。所以系统里所有联系方式默认脱敏展示,只有提交认领申请后,发帖人才能看到完整联系方式,并且有权同意或拒绝这次认领。这个设计我一直保留到了最终版本,实际使用里确实挡住了几次冒领尝试。
1.3 前后端分离,对这类项目意味着什么
校园失物招领规模不大,有些人可能会问:用 JSP + Servlet 写不就好了,何必前后端分离?我的回答是:如果只是应付交作业,怎么快怎么来;但如果想让这个项目"毕业之后还能继续演进",前后端分离带来的收益很实在。
- 前端是纯静态资源,可以由 Nginx 直接托管,后端只负责 API,两边可以分别部署、分别扩容。
- 开发期前后端可以并行:我定义好接口文档,前端同学按 mock 数据开发,互不阻塞。
- 后期如果要加微信小程序端、APP 端,后端 API 完全复用,只需要新写前端,成本低很多。
- 部署时前端一个 dist 目录,后端一个 jar 包,结构非常清晰,排查问题也不用来回翻 JSP 页面。
内存中"前端 8080、后端 9090"的格局也是基于这个分离思路来的。本机开发时用代理转发,线上用 Nginx 反向代理,全程不需要处理复杂的跨域问题,这部分我在第 6 节详细展开。
2. 技术选型复盘:这套组合是"最优解"还是"稳妥解"
2.1 SpringBoot:把繁琐的配置省掉,专注业务
后端框架选型其实没有什么悬念。SpringBoot 自 2.x 时代开始就是中小型项目的标配,它解决的最大痛点是自动化配置:内嵌 Tomcat、自动装配数据源、starter 机制,一个main方法就能启动整个服务,不需要再去手工配置一堆 XML 和 web.xml。
对于失物招领系统这种"页面数量中等、业务逻辑不深、但接口量不小"的项目,SpringBoot 的 Controller-Service-Mapper 三层结构非常匹配。它帮你省掉的是配置成本,留下来的业务代码完全是可读、可维护的。另外,SpringBoot 生态里的验证框架、Redis、文件上传、拦截器都有现成方案,后续扩展的道路是通的。
2.2 MyBatis:为什么不用 JPA,而选 XML 手写 SQL
这是个值得聊两句的选型问题。JPA/Hibernate 能少写很多 SQL,但它在复杂多表查询、动态筛选、连表分页上反而容易让人头疼;尤其在团队水平参差不齐的时候,自动生成的 SQL 出了问题很难排查。MyBatis 的逻辑更直白:你写什么 SQL,数据库就执行什么 SQL,出了问题直接把 XML 里的语句复制到 Navicat 跑一遍就知道结果。
失物招领系统的列表页有三个核心查询场景,全部适合 MyBatis 的动态 SQL:
- 按分类、状态、关键字组合筛选。
- 分页查询,配合 PageHelper 一个插件搞定。
- 批量操作,比如管理员批量下架,用
foreach实现UPDATE ... WHERE id IN (...)。
相比 JPA 的Specification,MyBatis 的 XML 写法对新手更友好,因为语句是可见的,你能清楚知道每一行在干什么。
2.3 Vue:组件化开发让页面不再是流水账
前端选择 Vue 的原因很朴素:上手曲线低、生态成熟、配套组件库齐全。这个项目我用的是 Vue 3 + Vite + Element Plus 或者 Vue 2 + Element UI 都可以,两者结构完全一样,差别只在依赖版本。
Vue 最大的优势是组件化。失物信息卡片、分类筛选栏、发布表单、分页器这些模块被拆成独立组件后,列表页和详情页可以复用同一套东西。最典型的例子是"物品卡片"这个组件,在首页、寻物列表、招领列表、个人中心、搜索结果里全都在用,一个组件改样式,所有页面同步生效,这个体验比传统模板引擎舒服太多。
2.4 工程目录怎么规划才不乱
前后端分离项目最忌讳的就是前后端代码混在一个仓库里。我建议就是一个前端目录、一个后端目录,各自独立。后端我习惯按这个结构组织:
lost-found-server ├── src/main/java/com/campus/lostfound │ ├── controller # 接口层 │ │ ├── AuthController.java │ │ ├── ItemController.java │ │ └── ClaimController.java │ ├── service # 业务层 │ │ ├── ItemService.java │ │ └── ClaimService.java │ ├── mapper # MyBatis Mapper接口 │ ├── entity # 实体类 │ ├── common # 统一返回、异常处理、工具类 │ └── config # 跨域、静态资源配置 └── src/main/resources ├── mapper # Mapper XML文件 └── application.yml前端目录:
lost-found-web ├── src │ ├── api # axios 接口封装 │ ├── router # 路由配置 │ ├── views # 页面级组件 │ ├── components # 公共组件(卡片、分页、上传等) │ ├── store # 用户状态管理 │ ├── utils # 工具方法 │ └── App.vue └── vite.config.js这样的好处是:找人找代码非常快。你看到一个接口,从 Controller 进 Service,再从 Service 进 Mapper,链路完全线性,没有任何绕路。
3. 数据库设计:失物招领系统需要几张表
3.1 用户表:角色和登录信息怎么存
用户表的设计比较常规,但有三个细节我建议不要省略:
password字段长度至少 100,存 BCrypt 加密后的结果,不要存明文。role字段用TINYINT而不是字符串,0 表示普通用户,1 表示管理员,扩展超级管理员时再加值即可。student_no学号字段虽然是可选的,但它能极大提升信任感。失主说明卡号后四位,拾到者核验起来就很方便。
CREATE TABLE `user` ( `id` INT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名', `password` VARCHAR(100) NOT NULL COMMENT 'BCrypt加密后的密码', `real_name` VARCHAR(50) DEFAULT NULL COMMENT '真实姓名', `student_no` VARCHAR(20) DEFAULT NULL COMMENT '学号/工号', `phone` VARCHAR(20) DEFAULT NULL COMMENT '联系电话', `avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像地址', `role` TINYINT NOT NULL DEFAULT 0 COMMENT '0普通用户 1管理员', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';这里有个很实在的建议:用户名登录比手机号登录简单可靠。用手机号登录要做好短信验证码,那需要额外对接服务;而校园场景里用户名就用自己的学号或拼音,既容易记忆又不需要第三方服务。
3.2 失物表和招领表:用一张表还是两张表
这是整个数据库设计里最重要的一个决策。很多同学会把寻物和招领拆成两张表,表结构几乎一模一样——标题、描述、地点、时间、图片、联系人。这样做的问题在于:认领记录要同时关联两张表,逻辑会绕;搜索时要分别查两张表再合并,分页会麻烦。
我的方案是一张item物品表,用type字段区分:
CREATE TABLE `item` ( `id` INT PRIMARY KEY AUTO_INCREMENT COMMENT '物品ID', `user_id` INT NOT NULL COMMENT '发布人ID', `type` TINYINT NOT NULL COMMENT '1寻物 2招领', `title` VARCHAR(100) NOT NULL COMMENT '标题', `description` TEXT COMMENT '物品详细描述/特征', `category` VARCHAR(50) DEFAULT NULL COMMENT '分类:证件/电子产品/钱包/其他', `place` VARCHAR(100) DEFAULT NULL COMMENT '丢失/拾到地点', `happen_time` DATETIME DEFAULT NULL COMMENT '丢失/拾到时间', `photo` VARCHAR(255) DEFAULT NULL COMMENT '物品图片地址', `contact` VARCHAR(100) DEFAULT NULL COMMENT '联系方式', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0进行中 1已解决 2已下架', `views` INT NOT NULL DEFAULT 0 COMMENT '浏览量', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '发布时间', `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', KEY `idx_type_status` (`type`, `status`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物品表';这样设计的好处是:搜索接口只需要查一张表,WHERE type = 1就是寻物列表,WHERE type = 2就是招领列表;发布、审核、下架、认领走的也是同一套逻辑。对校园失物招领这种业务来说,一张表方案显著降低了代码复杂度。
3.3 认领记录表:状态流转是核心
认领表是业务闭环的关键。它的核心是一个状态机:认领申请提交后是"待确认",发帖人核实后改为"同意"或"拒绝";一旦同意,物品表的状态也要同步变成"已解决"。
CREATE TABLE `claim` ( `id` INT PRIMARY KEY AUTO_INCREMENT COMMENT '认领记录ID', `item_id` INT NOT NULL COMMENT '物品ID', `user_id` INT NOT NULL COMMENT '认领人ID', `content` VARCHAR(500) DEFAULT NULL COMMENT '认领说明,如卡号后四位/特征描述', `contact` VARCHAR(100) DEFAULT NULL COMMENT '认领人联系方式', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待确认 1同意 2拒绝', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '申请时间', KEY `idx_item_id` (`item_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='认领记录表';这里有一个容易被忽视的问题:认领申请表里不该只有item_id,还应该冗余一个item_type或者通过item表反查类型。我的做法是:提交申请时先从item表查出该帖的类型,再做后续判断。这样做的原因是,认领通知要区分"有人认领了我的失物"还是"我认领了别人的失物",两种通知文案完全不同。
状态流转的规则我用一张表固定下来:
| 操作 | 前置状态 | 后置状态 | 数据表变更 |
|---|---|---|---|
| 用户提交认领 | item.status=0 | claim.status=0 | 插入 claim 记录 |
| 发帖人同意认领 | claim.status=0 | claim.status=1 | 同时把 item.status 改成 1 |
| 发帖人拒绝认领 | claim.status=0 | claim.status=2 | 无其他变更 |
| 管理员下架 | item.status=0/1 | item.status=2 | 关联 claim 可保留 |
3.4 索引和字段设计里的几个小讲究
- 大类匹配要建联合索引:列表页最常见的查询条件是
type + status,所以建了idx_type_status联合索引,避免全表扫描。 - 时间字段用 DATETIME 而不是 VARCHAR:虽然用字符串也能存"2025-03-01 10:30",但 DATETIME 可以直接做范围查询和排序,语义也更清晰。
- description 用 TEXT:物品特征描述可能比较长,用 VARCHAR(255) 会截断,用 TEXT 就不会。
- 状态字段用数字枚举而不是英文:
status = 0/1/2比status = 'pending'/'resolved'/'closed'存储更紧凑,查询更快。项目里写注释说明数字含义即可。
4. 后端开发实录:SpringBoot 接口层的实现细节
4.1 统一返回结构与全局异常处理
前后端分离的项目,第一件事就是约定接口返回格式。我统一用一个Result<T>类,所有 Controller 方法的返回值都走它:
@Data public class Result<T> { private Integer code; private String msg; private T data; public static <T> Result<T> ok(T data) { return new Result<>(200, "操作成功", data); } public static <T> Result<T> error(String msg) { return new Result<>(500, msg, null); } }前端所有地方就只判断code === 200,非常一致。再加上一个全局异常处理器@RestControllerAdvice,把业务异常、参数校验异常、系统异常统一转成 Result 格式返回,就不会出现"接口层把异常栈直接抛给前端"这种很难看的情况。
4.2 application.yml 配置与 MyBatis 驼峰映射
MyBatis 最容易被新手忽略的配置就是map-underscore-to-camel-case。数据库字段习惯用下划线命名happen_time,实体类属性习惯用驼峰命名happenTime,如果不开启自动驼峰映射,查询结果里happenTime永远是 null。
server: port: 9090 spring: datasource: url: jdbc:mysql://localhost:3306/lost_found?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 5MB max-request-size: 10MB mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true pagehelper: helper-dialect: mysql reasonable: true这里有几个细节必须说清楚:
- MySQL 8 的驱动类名是
com.mysql.cj.jdbc.Driver,MySQL 5 才是com.mysql.jdbc.Driver,用错会直接报加载驱动失败。 - URL 里必须加
serverTimezone=Asia/Shanghai,否则本地环境时区不对会报时间错乱或直接连接失败。 max-file-size和max-request-size不配的话,SpringBoot 默认允许 1MB,传一张手机照片大概率超限。
4.3 Mapper 层:动态 SQL 与分页查询
失物招领最重要的接口是列表查询,它支持按类型、状态、关键字、分类、页码筛选。用 MyBatis 动态 SQL 写就是:
<select id="selectByCondition" resultType="com.campus.lostfound.entity.Item"> SELECT * FROM item <where> <if test="type != null">AND type = #{type}</if> <if test="status != null">AND status = #{status}</if> <if test="category != null and category != ''"> AND category = #{category} </if> <if test="keyword != null and keyword != ''"> AND ( title LIKE CONCAT('%', #{keyword}, '%') OR description LIKE CONCAT('%', #{keyword}, '%') OR place LIKE CONCAT('%', #{keyword}, '%') ) </if> </where> ORDER BY create_time DESC </select>分页我用的是 PageHelper,三行代码就能给任意查询加上分页:
PageHelper.startPage(pageNum, pageSize); List<Item> items = itemMapper.selectByCondition(query); PageInfo<Item> pageInfo = new PageInfo<>(items);返回的PageInfo里自带total、pages、hasNextPage等字段,前端分页组件直接消费就行。这里有一个使用 PageHelper 的常见坑:startPage必须紧跟在你要分页的查询语句之前,中间如果穿插了其他查询,分页就会作用到错误的语句上。
4.4 文件上传与静态资源映射
发布失物招领信息几乎必然要传照片。后端接口设计为/api/upload,接收MultipartFile,保存到本地磁盘的upload目录,返回可访问的 URL 路径。
@PostMapping("/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return Result.error("文件不能为空"); } String fileName = UUID.randomUUID() + "_" + file.getOriginalFilename(); String dir = System.getProperty("user.dir") + "/upload/"; File dest = new File(dir + fileName); file.transferTo(dest); return Result.ok("/upload/" + fileName); }为什么文件名要用 UUID 加前缀?两个原因:一是防止中文文件名乱码和特殊字符导致路径解析失败;二是防止用户上传的a.jpg同名覆盖别人的图片。文件名存数据库时会非常难查,用 UUID 重命名是一劳永逸的做法。
然后要把上传目录暴露成静态资源,让前端能直接访问图片:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String path = System.getProperty("user.dir") + "/upload/"; registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + path); } }这段代码的意思是:访问/upload/xxx.jpg时,SpringBoot 去本地磁盘服务器工作目录/upload/xxx.jpg读取文件。这样,图片上传和访问的链路就闭环了。注意生产环境里上传目录一定要放在 jar 包外部(比如/opt/lost-found/upload/),否则重新部署 jar 包时文件会被一起清理掉。
4.5 Controller 层与简单登录校验
Controller 层我保持薄薄一层,只做参数接收和结果返回。以物品发布接口为例:
@RestController @RequestMapping("/api/item") public class ItemController { @Autowired private ItemService itemService; @PostMapping("/publish") public Result<Boolean> publish(@RequestBody Item item, @RequestHeader("Authorization") String token) { Integer userId = JwtUtil.parseToken(token).get("userId", Integer.class); itemService.publish(userId, item); return Result.ok(true); } @GetMapping("/list") public Result<PageInfo<Item>> list(@RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize) { return Result.ok(itemService.queryPage(pageNum, pageSize)); } }登录校验我用的方案是 JWT(JSON Web Token)。注册/登录接口成功后,后端返回一个有效期两小时的 token,前端存到 localStorage。需要登录的操作(发布、认领、审核)都要求请求头携带 token,后端用一个拦截器统一校验。拦截器里要注意放行登录、注册、列表、详情这些公开接口,不然前端一刷新页面,列表就全部 401 了。
4.6 Service 层事务边界怎么划
认领流程里有一个典型的"跨表写"场景:用户提交认领申请时,既要插入claim记录,又要考虑item的状态。这里我用了@Transactional保证原子性:
@Transactional public void applyClaim(Integer userId, ClaimRequest request) { Item item = itemMapper.selectById(request.getItemId()); if (item == null || item.getStatus() != 0) { throw new BusinessException("该物品不可认领"); } Claim claim = new Claim(); claim.setItemId(item.getId()); claim.setUserId(userId); claim.setContent(request.getContent()); claim.setContact(request.getContact()); claimMapper.insert(claim); }事务划到 Service 方法级别,Controller 不做任何和"事务"沾边的事。这个习惯建议从第一个项目就养成,不然后面一旦出现"插入了一半、异常退出、数据不一致"的情况,排查起来非常痛苦。
5. 前端开发实录:Vue 页面从零到能跑通
5.1 路由设计与页面清单
前端页面先按功能把路由定清楚,后面写页面才有方向。我采用的是 Vue Router 的 history 模式加懒加载:
const routes = [ { path: '/', component: () => import('@/views/Home.vue') }, { path: '/items/:type', component: () => import('@/views/ItemList.vue') }, { path: '/item/:id', component: () => import('@/views/ItemDetail.vue') }, { path: '/publish', component: () => import('@/views/Publish.vue') }, { path: '/mine', component: () => import('@/views/MyItems.vue') }, { path: '/login', component: () => import('@/views/Login.vue') }, { path: '/admin', component: () => import('@/views/Admin.vue') }, { path: '/:pathMatch(.*)*', redirect: '/' } ]其中两个点值得解释:
/item/:id是详情页路由,路径参数直接通过this.$route.params.id拿到详情页的物品 ID,前端再请求/api/item/detail?id=xxx。- 最后一个通配路由是兜底用的,不管用户乱输什么路径,都会被重定向到首页,而不是白屏或 404。
5.2 Axios 封装、拦截器与跨域处理
前端所有请求统一走一个 axios 实例,好处是 token 注入、错误提示、加载动画只用写一次:
import axios from 'axios' import { ElMessage } from 'element-plus' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = token } return config }) request.interceptors.response.use( res => { if (res.data.code === 200) return res.data.data ElMessage.error(res.data.msg) return Promise.reject(new Error(res.data.msg)) }, err => { ElMessage.error(err.response?.data?.msg || '网络异常') return Promise.reject(err) } ) export default request开发期的跨域问题我用 Vite 的代理解决,而不是在后端写 CORS:
// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:9090', changeOrigin: true } } } })这里的原理是:浏览器不允许跨域请求,但开发服务器的代理转发是服务器到服务器,不存在浏览器同源策略限制。所以前端代码里所有请求都写成/api/xxx的相对路径,开发环境由 Vite 转发,生产环境由 Nginx 转发,前端代码本身不需要知道后端在哪。
5.3 核心页面:发布表单与列表渲染
发布页是整个系统里交互最复杂的部分,涉及表单校验、图片上传、单选切换。核心逻辑是:用户选择"我要寻物"还是"我捡到了",然后填标题、描述、分类、地点、时间、联系方式、图片。
前端发布表单用 Element Plus 的el-form加校验规则,提交时把数据POST到/api/item/publish。图片上传组件是个独立封装,上传成功后把返回的 URL 存在表单字段里,提交时一起发给后端。
列表页是流量最大的页面,我拆成了"筛选条件区 + 物品卡片网格 + 分页器"。筛选条件区包含分类下拉框、关键字搜索框、进行中/已解决切换,每次筛选变化重新请求列表接口。物品卡片组件接收item对象,展示图片、标题、地点、时间和状态标签。卡片点击后跳转详情页。
这里有一个提升体验的小细节:详情页里的联系方式默认是脱敏的,例如138****1234。只有当用户点击"认领该物品"按钮,填写认领说明提交后,发帖人才会收到一条"有人申请认领"的通知,并且才能看到完整的申请人联系方式。这种保护机制会让用户更愿意发布真实信息,而不是担心隐私被滥用。
5.4 登录态管理与会话保持
我用 Vue 的状态管理库(Pinia 或 Vuex)存当前用户信息。登录成功后:
- 把
token存入 localStorage,有效期跟随后端 JWT 的设置(我设的是 2 小时)。 - 把
userInfo(用户名、角色、学号)存入 Pinia,供页面组件直接读取。 - 路由守卫里判断:需要登录的页面如果没有 token,自动跳转登录页。
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.matched.some(r => r.meta.requiresAuth) && !token) { next('/login') } else { next() } })注意一个常见的坑:token 过期后,不要只在前端判断有没有 token,还要在后端响应 401 时做统一处理。如果后端校验失败返回 401,前端拦截器应该清除 localStorage 并跳转登录页,否则会出现"页面看起来正常,一提交就报错"的奇怪状态。
6. 联调与部署:从本机跑到服务器,完整部署链路
6.1 本地联调:端口规划与跨域配置
本地联调阶段,我的端口规划是:
| 服务 | 端口 | 说明 |
|---|---|---|
| 前端开发服务器 | 5173(Vite)/ 8080(Vue CLI) | 浏览器直接访问的地址 |
| 后端 SpringBoot | 9090 | 注意尽量不要占用 8080,避免和前端冲突 |
| MySQL | 3306 | 默认端口 |
后端如果非要开 CORS 也行,但建议只在开发环境放开,否则生产环境里前后端互通容易埋雷。更推荐的方式是前后端都通过同域访问,也就是生产环境里用 Nginx 把/api反代到后端 9090,这样浏览器里的请求看起来就是同源,根本不需要 CORS。我在部署阶段就是走这条路线,后端的跨域代码可以安全移除。
6.2 前端构建与 Nginx 静态托管
前端部署前必须执行构建:
npm install npm run build构建完成后会生成dist目录,里面是纯静态文件(HTML、JS、CSS)。把这个目录上传到服务器,配置 Nginx 指向它:
server { listen 80; server_name your-server-ip; root /opt/lost-found-web; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:9090/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里最关键的配置是try_files $uri $uri/ /index.html;。它的作用是:当用户直接访问/item/5这种前端路由路径时,服务器发现磁盘上没有这个文件,就返回index.html,由 Vue Router 接管路由渲染。如果没有这行配置,刷新详情页立刻就会 404。
第二条 location 的作用是反向代理:凡是请求/api/**,Nginx 都转发给后端 9090 端口。注意proxy_pass后面的斜杠处理,proxy_pass http://127.0.0.1:9090/;表示去掉/api前缀再转发,后端接口才能正确匹配到/api/item/list。
6.3 后端打包:jar 包部署与 Tomcat 的关系
后端部署最简单的方式是打成一个可执行 jar 包:
mvn clean package -DskipTests在项目的target目录下会出现lost-found-0.0.1-SNAPSHOT.jar,然后直接运行:
java -jar lost-found-0.0.1-SNAPSHOT.jar这里要说明一个经常混淆的概念:SpringBoot 的 jar 包自带内嵌 Tomcat,所以不需要额外安装 Tomcat 也能运行,java -jar启动的就是一个内置 Web 服务器。但如果你非要部署到外部 Tomcat 的 webapps 目录,那需要做两件事:
- 修改
pom.xml的打包方式为war。 - 启动类继承
SpringBootServletInitializer并重写configure方法。
我的建议是:能用 jar 包就别用外部 Tomcat。jar 包部署的优势是环境隔离、依赖固定,只需一个 JDK 就能跑,升级时替换 jar 包重启即可;外部 Tomcat 部署还要考虑版本兼容、日志分离、类加载冲突等问题。
生产环境我建议再用一个守护工具。可以用nohup简单实现后台运行,也可以用 systemd 写成系统服务,实现开机自启和崩溃重启:
nohup java -jar lost-found-0.0.1-SNAPSHOT.jar > /opt/lost-found/logs/app.log 2>&1 &如果图省事,nohup 完全够用;如果想要更规范一点,写一个 systemd service 文件也就十几分钟的事。
6.4 数据库初始化与配置文件分离
数据库部分,我准备了一个init.sql脚本,包含建库、建表、插入管理员账号。服务器上执行:
mysql -u root -p < /opt/lost-found/init.sql生产环境的数据库配置不要写死在application.yml里,建议用环境变量或启动参数覆盖:
java -jar lost-found-0.0.1-SNAPSHOT.jar \ --spring.datasource.username=prod_user \ --spring.datasource.password=your_password这样做的好处是:同一份 jar 包可以根据环境参数切换数据库,不需要重新编译。管理员账号的初始化密码我建议用 BCrypt 加密后再插入数据库,千万不要初始化成一个明文密码放在脚本里。
部署完成后,验证顺序也有讲究:先验证数据库连通(登录 MySQL 看表有没有建好),再验证后端接口(浏览器访问http://服务器IP:9090/api/item/list,看能不能返回 JSON),最后验证前端页面(访问http://服务器IP/,看 Nginx 是否正常渲染)。这一步排查起来会非常高效。
7. 我踩过的几个坑,和后续扩展方向
7.1 最容易折腾人的四个问题
第一个坑:MySQL 8 时区问题和驱动问题。我第一次部署时用的是 MySQL 5 时代的驱动类名和旧连接字符串,启动直接报Public Key Retrieval is not allowed之类的错误。解决办法就是:驱动类用com.mysql.cj.jdbc.Driver,连接串加serverTimezone=Asia/Shanghai,再在 URL 后追加allowPublicKeyRetrieval=true&useSSL=false,一次解决。
第二个坑:MyBatis 查出来时间是 null。列表页明明有数据,但时间字段永远显示--。排查后发现是实体类属性用了happenTime,数据库字段是happen_time,忘记开启驼峰映射。配置加一行map-underscore-to-camel-case: true就好。如果你用的是@Param传多参数,还要注意 XML 里#{}的名字必须和@Param一致。
第三个坑:Vue history 路由刷新 404。开发环境一切正常,部署到 Nginx 后,点进去详情页再刷新,直接 404。原因就是 Nginx 没有配置try_files回退到index.html。这个在 6.2 节已经写清楚了,算是前后端分离部署的必踩之坑。
第四个坑:上传图片超过 1MB 报错。SpringBoot 默认上传大小上限是 1MB,手机随手拍的照片基本都是 2-5MB,前端一传就报MaxUploadSizeExceededException。除了在后端配置里调大限制,前端还要加一个图片压缩逻辑,或者用组件库自带的 before-upload 钩子限制文件类型和大小,双保险。
7.2 这个系统还能往哪些方向扩展
失物招领系统做完基础版之后,扩展空间其实很大:
- 搜索能力升级:当前基于 MySQL 的 LIKE 模糊查询只能满足小规模数据。数据量大了以后,可以引入全文索引或 Elasticsearch,支持按物品描述做更精准的匹配。
- 消息通知闭环:目前认领信息是站内通知,体验还可以。可以把通知升级为邮件或短信提醒,在有人认领自己发布的物品时,第一时间通知发帖人,加快认领速度。
- 移动端适配:校园场景里,学生用手机访问的比例远高于电脑。可以做一个移动端适配版本,或者直接基于现有后端 API 开发一个小程序端,这是一劳永逸的路线。
- 缓存优化:热门物品列表、首页推荐位可以放进 Redis 缓存,减少数据库压力。这个项目规模下不是必须的,但作为技术练习很有价值。
- 数据统计面板:给管理员加一个"丢失率、找回率、热门地点、热门分类"的统计图表,行政管理部门会非常喜欢这个功能。
我个人在实际操作中的体会是:这类前后端分离项目,真正花时间的不是写代码,而是把需求边界想清楚,把接口契约定好,把部署链路打通。失物招领系统本身不算复杂,但它把 SpringBoot、MyBatis、Vue、MySQL 这几个最主流的技术点全部串了一遍,每个环节都有值得深挖的细节。照着这个流程做一遍,你获得的不只是一套能跑通的源码,而是一整套"从需求到上线"的完整方法论。