大学里丢过校园卡的人应该都有这种体会:失物招领处永远在一楼值班室角落的纸箱子里,你跑去问的时候,值班阿姨只能让你自己翻。更难受的是捡到东西的人,一张学生卡被扔在桌上,想找失主只能等对方来找。后来我做了这个前后端分离的校园失物招领系统,用SpringBoot+Vue+MyBatis+MySQL这套组合,把“丢失”和“拾获”两条信息流真正打通了。这篇文章会从需求拆解、技术选型、数据库设计、前后端实现到部署上线,把整个项目的完整思路和实操步骤讲清楚。无论你是想完成毕业设计、课设项目,还是第一次接触前后端分离项目想找一份拿来就能跑的源码,这篇都能帮你省掉大量踩坑时间。
1. 从纸箱子到信息流:校园失物招领到底要解决什么问题
1.1 传统失物招领的痛点在哪里
很多校园项目一上来就追求功能多,但做失物招领系统之前,先得想明白一件事:这个系统的真正痛点是什么。
传统模式最大的问题是信息不对称。失主丢了东西,只能靠自己去食堂、图书馆、保安室挨个问;捡到东西的人,也不知道该把东西交给谁。两边都在找,却互相看不见。所以这个系统第一个要做的事情,就是搭一个公共的信息池——丢失信息和拾获信息都往里放,让双方可以搜索、比对、联系。
第二个痛点是认证和信任问题。校园场景里,谁也不能随便把别人捡到的物品拿走。系统里必须有一个身份标识,让用户知道对面是本校学生,同时留联系方式。我设计了最简单的学生认证方式:注册时填写学号和手机号,配合后台管理员的审核操作,既保证门槛不高,又确保信息可追查。
第三个痛点是状态管理。一个物品从丢失/捡拾到最终归还,中间的状态必须清晰:待认领、申请中、已归还、已关闭。没有状态机,列表里全是陈年旧信息,系统就废了。
1.2 系统角色与核心流程梳理
这个系统涉及的参与者比较清晰:普通学生用户、后台管理员。
普通用户的核心操作是四件事:
- 发布失物信息(我丢了什么)
- 发布招领信息(我捡到了什么)
- 浏览和搜索(能不能找到匹配)
- 提交认领申请(我觉得这条信息对应的就是我的东西)
管理员的核心操作是:
- 审核用户、管理异常信息
- 维护物品分类
- 查看平台整体数据
认领流程需要重点设计。整个闭环是:失主发布失物 → 捡到者发布招领 → 一方发现匹配信息 → 提交认领申请 → 对方确认并联系 → 线下核实身份 → 状态更新。这里面最关键的是让“提交申请”和“确认状态”两个动作在系统里形成闭环,而不是让双方下载手机号后在线下解决,最后连物品状态都没人更新。
1.3 功能模块拆解:不堆大而全,只做够用
我把系统拆成六个模块,不多不少:
| 模块 | 功能点 |
|---|---|
| 用户模块 | 注册、登录、个人中心、学号认证 |
| 失物模块 | 失物发布、列表浏览、关键字搜索、分类筛选、状态更新 |
| 招领模块 | 招领发布、列表浏览、关键字搜索、分类筛选、状态更新 |
| 认领模块 | 认领申请、申请审核、我的申请列表 |
| 分类管理 | 物品分类维护(卡类、电子设备、证件等) |
| 后台管理 | 用户管理、信息审核、数据统计 |
这里有一个设计取舍:很多类似项目会做“失物匹配推荐”“相似物品智能识别”,但对一个校园失物招领系统来说,这些功能投入产出比并不高。我更倾向于做扎实的搜索和分类筛选,让用户自己判断是否匹配。系统本身的定位是信息撮合平台,而不是自动判案系统。
2. 技术栈选型:为什么这套组合是校园项目的最优解
2.1 SpringBoot:一个人也能撑起后端
后端选型是SpringBoot,这个选择基本没有争议。
SpringBoot把Spring繁琐的XML配置全部干掉了,内嵌Tomcat,一个java -jar就能把服务跑起来。对校园项目来说,这意味着你可以把精力集中在业务逻辑上,而不是折腾SSH框架的配置地狱。这个项目我用的版本是2.7.18,对应JDK8,这两个版本组合在兼容性和稳定性上经过了非常充分的验证,不太会出现网上说的各种奇怪问题。
项目里用到的SpringBoot核心能力包括:Spring MVC处理HTTP请求、Spring Security的核心概念(虽然我用JWT做轻量鉴权)、Transactional事务管理、以及自动配置带来的便捷性。
2.2 Vue:页面变化的愉悦感
前端选Vue 2.6,配合ElementUI组件库。Vue的响应式数据绑定让“页面数据变成真”的过程非常自然:用户发一条失物信息,列表页马上就能看到;搜索时输入关键词,列表实时刷新。
Vue的组件化开发在失物招领系统里收益很明显。失物卡片、招领卡片、筛选栏、分页器这些UI模块,在多个页面中反复复用。我把它们抽成公共组件,每次加功能改样式都只动一个文件。
组件化还有一个特别实际的好处:我一个人维护整个项目时,不需要记住所有页面的细节,打开组件文件就能快速定位问题。后来项目扩展到后台管理页面,我只写了一小部分新代码,大部分组件都是直接从用户端复用的。
2.3 MyBatis与MySQL:SQL在手,数据我有
MyBatis在这个项目里的价值可以概括成四个字:SQL可控。Spring Data JPA虽然也很流行,但它自动生成的SQL在复杂多表查询时往往不够直观,出了问题排查很累。MyBatis让你每条SQL都清清楚楚写在XML文件里,性能问题一眼就能看出来。
MySQL作为数据库,对校园项目来说是性价比最高的选择:免费、稳定、生态成熟。我用的是MySQL 8.0,Java生态对它支持非常友好。
2.4 前后端分离与单体开发的分界线
有一次跟同学聊技术,他说:“前后端分离是不是有点过度设计?我一个人开发,为什么不用一个Thymeleaf模板一把梭?”这个问题问得很好。
我的回答是:前后端分离的核心优势不是“让前后端的人可以并行开发”,而是职责边界清晰。后端只负责返回JSON数据,前端只负责展示和交互。调试时后端可以用Postman测接口,前端可以用Mock数据;部署时前端挂Nginx,后端走Java进程,互不干扰。对校园项目来说,分离架构的调试体验好、可维护性强,虽然前期要处理跨域问题,但收益远大于成本。
这个项目里,前后端完全通过RESTful API通信,后端统一返回{code, message, data}结构,前端根据code判断业务是否成功。这是一个非常标准、也极其好维护的约定。
3. 数据库设计:失物、招领、认领三张表如何互相咬合
3.1 用户表与鉴权字段
数据库设计是整个系统最重要的环节,表结构设计得不好,后面所有功能都要返工。我从用户表开始想办法。
用户表(user)我定义了这些核心字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | BIGINT | 主键,自增 |
| username | VARCHAR(50) | 登录账号(学号) |
| password | VARCHAR(100) | BCrypt加密后的密码 |
| nickname | VARCHAR(50) | 昵称 |
| phone | VARCHAR(20) | 联系电话 |
| head_img | VARCHAR(255) | 头像图片URL |
| role | TINYINT | 0普通用户,1管理员 |
| status | TINYINT | 0禁用,1正常 |
| create_time | DATETIME | 注册时间 |
这里有个容易踩坑的点:密码字段别用明文,别用MD5,直接用BCryptPasswordEncoder。MD5在彩虹表面前形同虚设,BCrypt自带盐值,是目前最合适的选择。
用户表为什么用学号作为登录账号?因为这个系统本身就是封闭的校园场景,学号天然唯一,身份可信度也更高。后面管理员审核资料时,主要也是核对学号和姓名是否匹配。
3.2 失物表和招领表的设计理念
失物表和招领表是系统里最核心的两张业务表,它们的字段设计有很强的对称性——因为本质上是同一种信息的不同方向。
失物表(lost_item)核心字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | BIGINT | 主键 |
| user_id | BIGINT | 发布者用户ID |
| category_id | BIGINT | 物品分类ID |
| title | VARCHAR(100) | 物品名称 |
| description | TEXT | 详细描述(物品特征、丢失时间等) |
| location | VARCHAR(255) | 丢失地点 |
| images | VARCHAR(1000) | 图片URL列表,逗号分隔 |
| contact | VARCHAR(50) | 联系方式 |
| status | TINYINT | 0待认领,1已找到,2已关闭 |
| create_time | DATETIME | 发布时间 |
| update_time | DATETIME | 更新时间 |
招领表(found_item)结构几乎一模一样,只有语义不同:location字段记录的是“拾获地点”,status状态从“待认领”变为“待认领/已归还/已关闭”。
有一个字段设计上我纠结过很久:为什么用images逗号分隔的方式存多张图片,而不是新建一张图片表?后来想法是:这个系统的单条记录图片数量很少(最多3张),用逗号分隔存一个字段,可以省掉一次关联查询,前端拿到后split(',')就能直接用。如果哪天图片量大了再拆表,对这个系统来说基本不会发生。
3.3 认领申请记录表:一个表打通认领闭环
认领申请记录表(claim_record)是整个系统业务闭环的关键。
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | BIGINT | 主键 |
| item_type | TINYINT | 0失物申请认领,1招领申请认领 |
| item_id | BIGINT | 关联的失物/招领ID |
| apply_user_id | BIGINT | 申请者用户ID |
| owner_user_id | BIGINT | 发布者用户ID |
| message | VARCHAR(500) | 申请留言(说明物品特征) |
| status | TINYINT | 0待审核,1通过,2拒绝 |
| create_time | DATETIME | 申请时间 |
| handle_time | DATETIME | 处理时间 |
这里用item_type + item_id做多态关联,这是很有争议的一个设计决策。严格的关系型数据库设计会用两张表分别记录“失物认领申请”和“招领认领申请”。但我在实际开发中发现,它们的字段几乎一模一样,硬拆成两张表反而让查询逻辑重复。用item_type字段区分,一张表就能支撑两种业务场景。
owner_user_id为什么要冗余存一个?因为查询“我收到的申请”时可以直接按owner_user_id过滤,不用先根据item_type去查失物表或招领表再反查发布者。这个冗余换取的是性能便利,而且数据一致性在业务逻辑层面就控制了——发布者信息本来就是静态的,不会频繁变化。
3.4 索引设计与SQL注意事项
索引设计是数据库设计中最容易被忽略的部分。很多人在表建完之后就把索引忘了,等到数据量上来才发现查询慢得离谱。
这里我做了三个索引:
lost_item.create_time:列表页按时间排序,没有索引会全表排序found_item.create_time:同上claim_record.item_type, item_id:联合索引,支撑认领申请查询
另外有一件特别值得注意的事:MySQL 8.0里utf8mb4才是真正的UTF-8完整实现,utf8字符集在存特殊字符时可能出问题。建库时统一用utf8mb4,排序规则用utf8mb4_general_ci就够用了。
4. 后端实现:从接口规划到文件上传落地
4.1 项目结构与统一返回体
后端项目结构我按功能分包,而不是按技术分层分包。这样做的好处是改动一个功能时,相关的Controller、Service、Mapper都在一起,找代码很快。
com.example.lostfound ├── config # 跨域配置、WebMvc配置 ├── controller # 接口层 ├── service # 业务逻辑层 ├── mapper # MyBatis Mapper接口 ├── model # 实体类、DTO、VO ├── common # 统一返回、异常、工具类 └── interceptor # JWT拦截器Result<T>统一返回体是前后端协作的基础。每个接口都返回{code: 200, message: "success", data: ...},前端拿到code !== 200就弹错误提示,逻辑非常简单统一。这里给出一个比较简洁的写法:
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }同时配置全局异常处理器,Controller里就不需要到处try-catch了。业务异常直接throw new BusinessException("xxx"),全局处理器统一转成Result返回。这个设计能让业务代码干净非常多。
4.2 登录鉴权:JWT的配置与拦截器白名单
登录鉴权是前后端分离项目里绕不开的话题。Session方案在分离场景下要处理跨域Cookie,太麻烦;我直接用JWT方式,服务端不存状态,每次请求带上Token,后端解析校验身份。
JWT分为三部分:Header、Payload、Signature。Header声明加密算法,Payload放用户ID和过期时间,Signature用服务端密钥签名。我封装了一个简单的JwtUtil工具类,核心方法就三个:生成Token、解析Token、校验是否过期。
@Component public class JwtUtil { @Value("${jwt.secret}") private String secret; @Value("${jwt.expire}") private Long expire; public String generateToken(Integer userId, String role) { return Jwts.builder() .claim("userId", userId) .claim("role", role) .setExpiration(new Date(System.currentTimeMillis() + expire)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser().setSigningKey(secret) .parseClaimsJws(token).getBody(); } }这里有个特别关键的配置环节:拦截器白名单。注册、登录、列表浏览、搜索这些接口必须放行,否则没登录的用户什么都看不了。我在拦截器里维护一个白名单数组,用AntPathMatcher做路径匹配。这是个很细微但非常重要的逻辑。
针对跨域问题,后端配置了CORS:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowCredentials(true) .maxAge(3600); } }注意allowCredentials(true)会让浏览器在跨域请求时携带Cookie,如果前端用Token方式认证,其实可以不设这个。但保留它有一个好处:后续如果要扩展记住登录状态的Cookie功能,不用再改。
4.3 核心接口设计与MyBatis映射
后端接口按资源维度划分,下面是核心接口清单:
| 接口路径 | 方法 | 说明 |
|---|---|---|
| /api/user/register | POST | 用户注册 |
| /api/user/login | POST | 用户登录 |
| /api/user/info | GET | 获取当前用户信息 |
| /api/lost/list | GET | 分页查询失物列表(支持关键字、分类筛选) |
| /api/lost/add | POST | 发布失物 |
| /api/lost/update | PUT | 修改失物信息 |
| /api/lost/status | PUT | 更新失物状态 |
| /api/found/list | GET | 分页查询招领列表 |
| /api/found/add | POST | 发布招领 |
| /api/found/update | PUT | 修改招领信息 |
| /api/found/status | PUT | 更新招领状态 |
| /api/claim/add | POST | 提交认领申请 |
| /api/claim/my | GET | 我提交的申请 |
| /api/claim/received | GET | 我收到的申请 |
| /api/claim/handle | PUT | 处理认领申请 |
| /api/category/list | GET | 获取分类列表 |
| /api/statistics/summary | GET | 后台统计汇总 |
以失物列表接口为例,它的MyBatis XML映射值得重点讲。因为这里的动态SQL逻辑很典型:位置有记录、数字类型区分了相关参数,能够表达搜索、过滤与排序组合的场景:
<select id="selectLostList" resultType="com.example.lostfound.model.vo.LostItemVO"> SELECT li.id, li.title, li.description, li.location, li.images, li.contact, li.status, li.create_time, u.nickname AS publisherName, u.head_img AS publisherAvatar, c.name AS categoryName FROM lost_item li LEFT JOIN user u ON li.user_id = u.id LEFT JOIN category c ON li.category_id = c.id <where> <if test="keyword != null and keyword != ''"> AND (li.title LIKE CONCAT('%', #{keyword}, '%') OR li.description LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="categoryId != null"> AND li.category_id = #{categoryId} </if> </where> ORDER BY li.create_time DESC </select>用LEFT JOIN而不是INNER JOIN,是为了防止用户信息或分类被删除后导致列表查不出来。外键关联字段,宁可显示NULL,也不能让主数据消失。
分页参数我通过PageHelper插件处理,一行PageHelper.startPage(pageNum, pageSize)就能完成物理分页,返回PageInfo直接带总条数、总页数等信息,前端分页组件可以直接用。
4.4 图片上传:本地存储与访问路径配置
图片上传是一个前后端分离项目里非常容易出问题的环节。上传本身不难,难的是让前端能用URL访问到上传后的图片。
我在后端实现了一个简单的图片上传接口:接收MultipartFile,校验类型和后缀名,生成UUID文件名+原后缀,保存到本地磁盘目录,如/data/lostfound/uploads/,返回给前端一个相对路径/uploads/20250601/xxxxx.jpg。
同时需要配置静态资源映射,让SpringBoot能直接对外提供这个目录的访问:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/uploads/**") .addResourceLocations("file:" + uploadPath + "/"); } }这里有个容易踩坑的细节:uploadPath结尾一定要带斜杠,否则路径拼接会出错。我一开始把路径配成了/data/lostfound/uploads,结果映射死活不生效,日志排查了半天才发现是少了最后那个/。
关于上传,另一个重要经验是前端必须做限制。我配合ElementUI的el-upload组件,设置accept="image/*",并且在before-upload里检查文件类型和大小。前后端双重校验是安全底线,绝不能只依赖前端。
5. 前端实现:Vue页面从路由到组件落地
5.1 Vue项目结构与环境
前端用Vue CLI创建项目,Node版本建议16.x,npm包管理。需要注意:如果直接装了最新版Node,可能和Vue CLI 4.x的webpack版本冲突,最后会弹出各种奇怪的编译错误。项目结构保持标准方式,但按模块划分了视图组件:
src/ ├── api/ # 接口请求封装 ├── assets/ # 静态资源 ├── components/ # 公共组件(失物卡片、筛选栏等) ├── router/ # 路由配置 ├── store/ # 状态管理(简单用户信息) ├── views/ # 页面组件 │ ├── home/ # 首页 │ ├── lost/ # 失物相关页面 │ ├── found/ # 招领相关页面 │ ├── user/ # 个人中心、登录注册 │ └── admin/ # 后台管理页面 └── utils/ # 工具函数5.2 路由设计:信息流页面怎么组织
路由设计对用户体验影响很大。这个系统的路由我设计成这样:
| 路径 | 页面 | 是否需要登录 |
|---|---|---|
| / | 首页(最新失物+招领信息流) | 否 |
| /lost | 失物大厅(可搜索筛选) | 否 |
| /found | 招领大厅(可搜索筛选) | 否 |
| /detail/lost/:id | 失物详情 | 否 |
| /detail/found/:id | 招领详情 | 否 |
| /publish/lost | 发布失物 | 是 |
| /publish/found | 发布招领 | 是 |
| /mine | 个人中心 | 是 |
| /mine/claims | 我的认领申请 | 是 |
| /admin | 后台管理 | 是(管理员) |
路由守卫是前端安全的比较重要的组成部分。虽然后端接口有JWT鉴权,但前端依然要做路由守卫,否则未登录用户访问/publish/lost时会直接看到空白页或报错。用router.beforeEach拦截未登录访问:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }) } else if (to.meta.requiresAdmin && token && localStorage.getItem('role') !== '1') { next({ path: '/' }) } else { next() } })登录后跳转回原页面的redirect参数是个很值得做的小功能。用户没登录时点击“发布失物”,被引导到登录页,登录完成后自动回到发布页面,体验流畅很多。
5.3 axios封装与API对接
前端的API请求封装,所有请求都走同一个axios实例,统一拦截器处理Token注入和响应码判断。这是前端项目中必须做好的基础设施。
import axios from 'axios' import { Message } from 'element-ui' import router from '@/router' const service = axios.create({ baseURL: process.env.VUE_APP_BASE_URL, timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { Message.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res.data }, error => { if (error.response) { if (error.response.status === 401) { localStorage.removeItem('token') localStorage.removeItem('userInfo') Message.error('登录已过期,请重新登录') router.push('/login') } else { Message.error(error.response.data.message || '请求异常') } } else { Message.error('网络异常,请稍后重试') } return Promise.reject(error) } ) export default service重点说一下401处理。JWT过期后,后端返回401,前端如果什么都不做,用户会一直停留在旧页面,点任何按钮都报错。我在拦截器里统一判断状态码,过期后清掉本地登录信息,跳转到登录页,同时弹一个“登录已过期”的提示。这个细节看似不起眼,实际体验差异非常大。
API层每个接口单独封装成一个函数,比如失物模块的请求都放在src/api/lost.js:
import request from '@/utils/request' export function getLostList(params) { return request({ url: '/api/lost/list', method: 'get', params }) } export function addLost(data) { return request({ url: '/api/lost/add', method: 'post', data }) } export function updateLostStatus(id, status) { return request({ url: '/api/lost/status', method: 'put', data: { id, status } }) }5.4 页面组件:卡片列表、筛选栏与发布表单
前端页面我做得最用心的是首页信息流。它同时展示最新失物和招领信息,采用上下两块Tab切换,每块都是卡片列表。
失物卡片是复用率最高的组件,展示标题、分类标签、丢失地点、发布时间,点击进入详情。卡片右上角有个状态标签,不同状态显示不同颜色:红色“待认领”、绿色“已找到”、灰色“已关闭”。这个设计让用户扫一眼就能知道信息的时效性。
筛选栏组件也是高度复用的。失物大厅和招领大厅顶部都有搜索框和分类下拉选择,搜索框支持按物品名称、地点关键字搜索,分类下拉从后端加载分类表数据。筛选条件变化后重新请求接口,同时更新URL的query参数,这样用户刷新页面后筛选条件还在。
发布表单做过三个版本,最后确定的是:表单拆分步骤比单独一个大表单体验更好。
- 第一步:选择发布类型(失物还是招领)
- 第二步:填写关键信息(物品名称、分类、地点、详细描述、联系方式)
- 第三步:上传图片(最多三张,可实时预览)
6. 本地跑通与部署上线:关键步骤与踩坑实录
6.1 环境准备:JDK、MySQL、Node、Nginx
部署整套系统前要准备环境,我假设目标服务器是CentOS 7+或Ubuntu 20.04+,整个过程主要依赖命令行操作。
后端运行环境:
- JDK 1.8(我强烈建议直接用1.8,SpringBoot 2.7系列对它的支持非常成熟)
- Maven 3.6+(打包用)
- MySQL 8.0(数据库)
前端构建环境:
- Node 16.x + npm(打包用)
- Nginx(作为静态文件服务器)
启动后端前先确认MySQL已启动,并发起建库操作:
mysql -u root -p CREATE DATABASE lostfound DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;然后导入项目自带的SQL脚本,脚本里包含了建表语句和初始化分类数据。
后端application.yml里面需要改成自己环境的配置:
server: port: 9090 servlet: context-path: / spring: datasource: url: jdbc:mysql://127.0.0.1:3306/lostfound?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: yourpassword mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.lostfound.model configuration: map-underscore-to-camel-case: true jwt: secret: your-secret-key expire: 6048000006.2 后端打包与启动:jar包的一站式运行
环境准备好后,后端打包非常简单:
mvn clean package -DskipTests打包完成后,在target目录下会生成一个lostfound-0.0.1-SNAPSHOT.jar。直接启动:
java -jar lostfound-0.0.1-SNAPSHOT.jar本地调试时用上面这种方式就够了。部署到服务器后台运行,我建议用nohup:
nohup java -jar -Xms256m -Xmx512m /opt/lostfound/lostfound-0.0.1-SNAPSHOT.jar > /opt/lostfound/log/log.out 2>&1 &-Xms和-Xmx设置JVM初始和最大堆内存。校园项目512MB完全够了,不需要给太多,服务器资源也是钱。
启动后验证接口是否正常,可以执行:
curl -X POST http://127.0.0.1:9090/api/user/login \ -H "Content-Type: application/json" \ -d '{"username":"test","password":"123456"}'能返回code: 200就说明后端已经跑通了。
6.3 前端打包与Nginx配置:刷新404的坑
前端打包同样简单:
npm install npm run build生成的dist目录就是需要部署的静态文件。把它拷贝到服务器,我放在/opt/lostfound/dist目录。
Nginx配置是整个部署过程中最常见的“翻车点”,特别是“前端路由刷新404”的问题。Vue Router如果使用默认的hash模式(URL带#号),不会遇到刷新404,但URL很丑。用history模式的话(URL是干净的/lost路径),直接访问http://域名/lost时Nginx会去服务器上找/lost这个文件,找不到就返回404。解决办法是在location /里配置try_files回退到index.html:
server { listen 80; server_name yourdomain.com; root /opt/lostfound/dist; index index.html; # 前端路由history模式必须配置 location / { try_files $uri $uri/ /index.html; } # 后端API反向代理 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; } # 图片等上传文件访问 location /uploads/ { alias /data/lostfound/uploads/; } }6.4 部署后最容易翻车的三个细节
部署完成后,前端能访问、后端接口也通了,但很多人在测试过程中会遇到几个隐蔽的问题,我可以分享一下经验。
第一个是数据库时区问题。如果你在数据库的DATETIME字段里存的时间比北京时间少了8个小时,多半是数据库连接URL里缺了serverTimezone=Asia/Shanghai。加上之后重启服务就能解决。这是一个非常典型的时序问题,每次重新配置环境都可能遇到。
第二个是图片显示不出来。前端页面上图片的URL是后端返回的相对路径,比如/uploads/xxx.jpg。开发环境下前端通过Vue CLI构建的代理服务器访问,但生产环境必须保证Nginx配置了location /uploads/指向实际存储目录。这里最容易犯的错误是忘记启动后检查上传目录的用户权限,导致Nginx进程没有权限读取该目录。用chmod -R 755或者将上传目录归属改为Nginx运行用户都能解决。
第三个是跨域配置与Nginx代理叠加的逻辑。当你已经配置了Nginx反向代理后,前端请求/api已经走了Nginx转发,所以浏览器里看到的请求是“同源”的——此时后端CORS配置不是必须的。但本地开发时前端跑在8080端口、后端跑在9090端口,就会出现跨域,所以后端CORS配置仍然要保留。这个“开发时跨域、生产时同源”的双重逻辑,很多人一开始会绕晕。
还有一个小经验:Nginx配置修改后一定要记得nginx -s reload,而不是直接重启整个Nginx服务,否则可能出现短暂的服务中断。配置完直接访问测试接口,确认所有功能正常再收工。
这套系统的完整实现到此就全部梳理完了。我个人在做了这个项目之后最大的感受是:前后端分离不是银弹,但它确实把一个人的项目开发效率提升了不少。后端接口不好用了,直接Postman调;前端样式崩了,打开浏览器DevTools就行,两边不用互相扯皮。如果你打算找一个靠谱的课程设计或毕业设计项目,这套SpringBoot+Vue+MyBatis+MySQL的失物招领系统源码和部署流程可以直接拿去做二次开发,数据库表结构、接口设计、前端组件这些基础都铺好了,你要做的就是往里面填充自己的校园特色功能。如果部署过程中遇到什么问题,可以按这篇文章的排查思路一步步过,大部分问题都出在环境配置和路径映射这两个环节。