做毕设选题的时候,我在网上刷到最多的居然是各种图书管理系统、学生信息管理系统,代码质量参差不齐,答辩撞车率极高。后来我换了个思路,选了一个不算新但极少有现成代码可抄的题目——失踪人员信息发布与管理系统。这题目的好处在于:它既有信息管理系统的标准增删改查,又有图片上传、条件检索、状态流转这些相对有含金量的功能点,技术栈用SpringBoot+Vue前后端分离,数据库上MySQL,难度正好卡在“能独立完成”和“有东西可讲”之间。这篇就按我自己做这套源码的思路,从选题动机、系统功能、数据库设计、后端实现、前端搭建到部署答辩的坑位,完整过一遍,给正在选毕设或课设题目的同学一个可以直接参考的路线。
1. 为什么失踪人员管理系统是毕设/课设的“稳妥之选”
1.1 业务真实感带来的天然优势
每年毕设季,各平台被刷屏的几乎都是图书管理系统、学生管理、宿舍管理。这些题目不是不能做,而是太同质化。一个答辩组里十几个“图书管理系统”,老师看到封面就能猜到源码出处,问的问题一个比一个刁钻。失踪人员信息发布与管理系统的第一个优势,是它自带社会意义和真实场景,评委看到这个选题本身就比较容易产生好感,提问方向也更偏向业务逻辑,而不是死磕实现细节。
从业务复杂度的角度说,这个系统的数据模型天然带有“发布—审核—反馈—结案”的流转过程,不是简单的一张表做到底。失踪人员信息需要登记人填写,管理员审核后才能公开展示,普通用户看到信息后可以提交线索,线索核实后信息最终结案归档。这条闭环本身就覆盖了权限角色、状态流转、文件上传、消息反馈等多个模块,做出来是一个真实可用的平台,不是玩具项目。
1.2 技术点覆盖与工作量评估
技术栈锁定 SpringBoot + Vue + MySQL 是保守且稳妥的选择。这套组合是当前高校 Java 方向毕设的主流配置,导师认可度高,网上资料也最全,出问题时容易排查。SpringBoot 负责后端 API 和权限控制,Vue3 + Element Plus 负责管理后台和门户页面,MySQL 存储所有业务数据,前后端通过接口交互,结构清晰,分工明确。
我评估过工作量,按一个人每天四到六小时算,大概三到四周可以完整跑通。具体拆分如下:
| 模块 | 涉及技术点 | 预估工作量 |
|---|---|---|
| 用户与权限 | JWT 登录、角色拦截 | 3-4 天 |
| 失踪信息管理 | 多条件分页查询、图片上传、状态流转 | 5-6 天 |
| 审核模块 | 审核列表、通过/驳回、审计日志 | 3-4 天 |
| 线索反馈 | 关联查询、提交防重 | 2-3 天 |
| 公告与统计 | 公告 CRUD、ECharts 图表 | 3-4 天 |
| 部署测试 | 前后端联调、Nginx 部署、答辩准备 | 3-5 天 |
这个难度梯度适合大多数有 Java 基础但还没有完整全栈经验的同学,不至于做不完,也不至于没有挑战性。
2. 系统功能全貌与核心业务闭环拆解
2.1 三种角色与权限边界
我把系统设计成三种角色:普通用户、信息登记员、系统管理员。实际修改源码时,登记员和管理员可以合并成后台用户,但设计上分开会更好讲。
普通用户不需要登录就能浏览已发布的失踪人员信息,支持按姓名、性别、地区、失踪时间段等条件检索,进入详情页查看照片和特征描述,并且可以提交线索反馈。信息登记员登录后可以提交失踪人员信息,填写被寻人基本资料、身体特征、穿着描述、失踪时间和地点,并上传照片,提交后进入待审核状态,在审核通过前可以撤回修改。管理员负责审核登记信息,通过后对外发布,也可以驳回并填写理由;对已发布的线索进行核实,确认找到后执行结案归档;维护公告内容和查看统计数据。
权限边界这件事要特别注意:前端菜单只是体验层面的控制,真正的权限校验必须在后端接口做。比如管理员审核接口,如果只靠前端隐藏按钮,用户直接构造请求就能越权提交,这是答辩时容易暴露的大问题。
2.2 核心业务流转:登记、审核、发布、结案
整个系统的核心是一条完整的状态机。我用文字梳理一下流程,方便后面建表时对照:
- 登记人填写表单,提交后信息状态为“待审核”。
- 管理员在后台看到待审核列表,查看详情,做两个选择:
- 通过:信息状态变为“已发布”,对外可见;
- 驳回:状态变为“已驳回”,并写入驳回理由,登记人可以看到并修改后重新提交。
- 已发布信息可以被普通用户检索和浏览,用户可提交线索反馈。
- 管理员核实线索,确认寻人成功后,信息状态变为“已结案”,归档保存。
每次审核动作都要写入审计日志,记录操作人、操作时间、操作类型和备注。这既是功能要求,也是答辩时展示工程素养的好素材。千万不要把状态流转逻辑写在 Controller 里,应该收敛到 Service 层,用统一的方法处理,保证状态只能按预定义的方向变化。
2.3 还需补齐的外围功能
主流程之外,系统还需要几个外围功能模块。公告栏用于发布寻人公告和进展说明,让门户首页有内容更新;账户管理负责用户注册、密码修改和管理员账号维护;统计看板是加分项,展示本月新增登记人数、当前在寻人数、已结案人数、结案率,以及按月份或省份分布的趋势图。
这里我建议用 ECharts 做两个图表:一个折线图展示近六个月的登记趋势,一个饼图展示当前信息状态分布。统计接口直接写聚合 SQL,数据量不大,不需要引入额外的报表工具。
3. 数据库设计:从实体关系到关键表字段规划
3.1 数据表清单与关系说明
数据库我最终建了六张表,去掉冗余后结构非常清晰:用户表、失踪人员信息表、线索反馈表、公告表、审核日志表,以及一个可选的操作日志表。核心是失踪人员信息表,其他表都围绕它扩展。
关系上,用户表与失踪信息表是一对多,登记人可以提交多条记录;失踪信息表与线索表是一对多,一条信息能收到多条线索;审核日志表与失踪信息表也是一对多,每个审核动作都留下记录。表关系越简单,MyBatis Plus 写起来就越省事,后期也不容易出现 JOIN 混乱。
3.2 失踪人员信息表核心字段设计
核心表我命名为 missing_person,关键字段设计如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,自增 |
| name | varchar(50) | 被寻人姓名 |
| gender | tinyint | 0 未知,1 男,2 女 |
| age | int | 失踪时年龄 |
| height | int | 身高(cm) |
| build | varchar(50) | 体型特征 |
| face_features | varchar(255) | 面部特征描述 |
| clothing_desc | varchar(255) | 失踪时穿着描述 |
| missing_date | datetime | 失踪时间 |
| missing_address | varchar(255) | 失踪地点 |
| contact_name | varchar(50) | 联系人姓名 |
| contact_phone | varchar(20) | 联系人电话 |
| photo_url | varchar(255) | 照片访问路径 |
| status | tinyint | 0 待审核,1 已发布,2 已结案,3 已驳回 |
| create_user_id | bigint | 登记人 ID |
| audit_user_id | bigint | 审核人 ID |
| audit_time | datetime | 审核时间 |
| audit_remark | varchar(255) | 审核备注/驳回理由 |
| create_time | datetime | 创建时间 |
| update_time | datetime | 更新时间 |
有几个字段的设计理由值得说明。照片字段存相对路径而不是 Base64,照片文件放磁盘目录,数据库只存访问路径,这样既减小数据库体积,图片加载也由静态资源服务器处理,效率更高。状态字段用数字而不是字符串,配合代码里的常量枚举使用,避免魔法值散落在代码里。联系人和被寻人信息分开存储,保证志愿者的个人隐私不会直接暴露在页面上,展示时只显示脱敏后的联系电话。时间字段全部用 datetime,方便做范围查询和排序。
3.3 状态与索引设计的考量
状态机设计是从建表就要想清楚的。0 待审核、1 已发布、2 已结案、3 已驳回,这四个状态缺一不可。驳回态是很多人会漏掉的,漏掉之后登记人就没法重新提交,系统流程会走死。
索引方面,最核心的查询场景是门户首页和后台列表的多条件检索。我给 name 建了普通索引,给 status 和 missing_date 建了联合索引,执行频率最高的查询就是“按状态 + 时间排序的分页列表”。需要注意的是,LIKE '%关键字%' 形式的模糊查询在数据量小时用普通索引就够了,但如果未来数据量增长明显,就要考虑全文索引或者引入搜索引擎,毕设阶段不需要过度设计。
4. 后端实现:SpringBoot 分层架构与核心接口逻辑
4.1 工程结构与依赖选型
后端工程我用的标准单模块结构,按包分层:controller 层只做参数接收和结果返回,service 层写业务逻辑,mapper 层操作数据库,entity 层对应数据表,config 放配置类,common 放统一返回体和异常处理,utils 放工具类。这种结构简单直观,答辩时也容易讲清楚。
依赖选型上,核心是这几个:spring-boot-starter-web 提供 Web 能力;mybatis-plus-boot-starter 替代传统的 MyBatis 配置,省掉大量 XML;mysql-connector-java 连接数据库;jjwt 做 Token 生成和校验;hutool 工具库处理日期、加密等杂活;jackson 做 JSON 序列化。为什么用 MyBatis Plus 而不是原生 MyBatis?最直接的原因是单表 CRUD 完全不用手写 SQL,内置的分页插件也能解决分页问题,能把精力集中在业务逻辑上,非常适合毕设周期。
4.2 统一返回体与全局异常
前后端分离项目,接口返回格式必须统一,否则前端判断逻辑会非常混乱。我的统一返回体是一个泛型 Result 类,包含 code、message、data 三个字段,业务成功返回 200,失败返回对应错误码,前端只看 code 就知道请求是否成功。
全局异常处理用 @RestControllerAdvice 实现。我定义了三种异常处理器:业务异常(例如状态流转不合法、参数缺失)、参数校验异常(@Valid 触发的字段校验失败)、兜底异常(捕获所有未知 Exception 返回友好提示)。这样后端任何位置抛异常,前端拿到的都是一个结构一致的错误响应,排查问题非常方便。
4.3 条件检索与状态流转的关键代码
多条件分页检索是这个系统最核心的接口,我用 MyBatis Plus 的 LambdaQueryWrapper 实现,避免字符串字段名写错在编译期都无法发现的问题。核心代码如下:
public PageResult<MissingPersonVO> pageList(MissingPersonQuery query) { Page<MissingPerson> page = new Page<>(query.getPageNum(), query.getPageSize()); LambdaQueryWrapper<MissingPerson> wrapper = new LambdaQueryWrapper<>(); // 条件拼接 if (StringUtils.hasText(query.getName())) { wrapper.like(MissingPerson::getName, query.getName()); } if (query.getStatus() != null) { wrapper.eq(MissingPerson::getStatus, query.getStatus()); } if (StringUtils.hasText(query.getProvince())) { wrapper.like(MissingPerson::getMissingAddress, query.getProvince()); } if (query.getStartDate() != null && query.getEndDate() != null) { wrapper.between(MissingPerson::getMissingDate, query.getStartDate(), query.getEndDate()); } // 默认按失踪时间倒序 wrapper.orderByDesc(MissingPerson::getMissingDate); Page<MissingPerson> result = missingPersonMapper.selectPage(page, wrapper); // 转换 VO,脱敏联系人电话 List<MissingPersonVO> voList = result.getRecords().stream() .map(this::convertToVO) .collect(Collectors.toList()); return new PageResult<>(result.getTotal(), voList); }状态流转的关键是禁止在 Controller 里直接调用 updateById 改状态。我设计了专门的 auditPass 和 auditReject 方法,内部校验当前状态和角色权限,然后统一更新状态字段并写入审计日志。例如审核通过:
@Transactional public void auditPass(Long id, Long auditUserId) { MissingPerson person = missingPersonMapper.selectById(id); if (person == null) { throw new BusinessException("记录不存在"); } if (!person.getStatus().equals(StatusEnum.PENDING.getCode())) { throw new BusinessException("当前状态不可审核"); } person.setStatus(StatusEnum.PUBLISHED.getCode()); person.setAuditUserId(auditUserId); person.setAuditTime(LocalDateTime.now()); missingPersonMapper.updateById(person); // 写入审计日志 AuditLog log = new AuditLog(); log.setMissingPersonId(id); log.setAction("audit_pass"); log.setOperatorId(auditUserId); log.setRemark("审核通过"); auditLogMapper.insert(log); }注意方法上加了 @Transactional,因为审核通过需要同时更新主表和写入日志,两个操作必须保持事务一致,不能一个成功一个失败。
4.4 图片上传的正确姿势
图片上传是这套系统里最容易踩坑的模块。上传接口非常简单:接收 MultipartFile,做类型和大小校验,用 UUID 重命名文件,保存到服务器磁盘目录,然后把可访问的 URL 返回给前端。文件类型只允许 jpg、png,大小限制在 5MB 以内,前端上传组件里同步做一轮预校验,避免用户传了一个大文件等了半天才提示失败。
文件保存路径不能写到项目源码的 static 目录下,否则打包成 jar 后图片会丢失,而且每次重新部署都会覆盖。正确做法是保存到独立的磁盘路径,比如 data/upload 目录,然后通过配置类把该目录映射为静态资源:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Value("${upload.path}") private String uploadPath; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadPath + "/"); } }这样配置之后,用户上传的图片通过 http://localhost:8080/upload/xxx.jpg 即可访问。前端上传成功后拿到返回的 URL,存在表单字段里提交给后端。这里的经验是,URL 存相对路径就好,不要拼接域名,否则以后换端口或换服务器,旧数据全都访问不了。
5. 前端实现:Vue3 + Element Plus 页面搭建要点
5.1 页面组织与路由设计
前端我用 Vue3 + Vite + Element Plus + Pinia + Vue Router 的组合。页面清单如下:
- 门户首页:展示已发布信息列表,卡片式布局,支持分页和顶部检索栏;
- 信息详情页:展示照片、特征、联系人脱敏信息,底部提供线索提交表单;
- 信息登记页:分步表单,包含基础信息、特征描述、照片上传;
- 登录页:账号密码登录,成功后进入后台;
- 管理后台页面:信息审核列表、公告管理、线索管理、统计看板。
路由设计中,门户页面和后台页面分离,后台路由统一带 /admin 前缀,配合路由守卫做角色拦截。守卫的逻辑很简单:进入后台前判断 localStorage 里的 token 和用户信息,如果不存在直接跳登录页;存在但角色不是管理员则跳首页。
5.2 核心业务页面的交互细节
审核页面是整个后台交互最复杂的页面。左边是待审核列表,使用 el-table 展示基本信息,点击行弹出详情抽屉,展示完整字段和照片预览。抽屉底部放两个按钮:通过、驳回。点通过直接调用接口;点驳回会弹出一个小对话框让管理员填写驳回理由,理由是必填的,填完再提交接口。
这里有个细节很容易忽略:审核列表和已发布列表是同一个接口的分页查询,只是传的 status 参数不同。前端把筛选条件作为响应式状态维护,切换状态时重置页码为 1,避免停留在不存在的那一页上,这个问题不处理的话用户会以为数据丢了。
照片上传组件我直接用 el-upload,配置 action 指向后端上传接口,headers 里带上 token。因为上传接口需要登录权限,不带 token 会被拦截。文件校验除了后端做,前端也做一遍,上传前检查文件类型和大小,不符合直接提示不发起请求。
检索区域是典型的“表单 + 按钮”布局,包含姓名输入框、状态选择框、时间范围选择器。点查询按钮时重新请求第一页数据,点重置按钮时清空所有条件并刷新列表。这里我提醒一下,时间范围选择器绑定的是一个数组,提交前要拆成 startDate 和 endDate 两个字段,字段名要和后端接收的命名一致,否则查询条件无效。
5.3 Axios 封装与权限拦截
前端所有请求都通过一个统一的 axios 实例发起。我在实例中配置了 baseURL,开发环境指向 Vite 代理地址,生产环境指向 Nginx 反向代理地址。请求拦截器从 localStorage 读取 token,放到请求头 Authorization 字段;响应拦截器统一处理各种响应情况:
- 后端返回 code 非 200,弹出对应错误提示;
- HTTP 状态码为 401,说明 token 失效或未登录,清除本地登录信息并跳转登录页;
- HTTP 状态码为 500,提示服务器异常。
这套封装写好后,每个页面只需要调用具体接口方法,不用重复处理错误逻辑。Pinia 里我只存了一份用户信息和权限角色,登录时写入,退出时清理,全局组件通过它判断当前登录用户是谁。
6. 前后端联调、部署上线与常见坑位实录
6.1 跨域与代理配置
前后端分离项目首先要解决跨域问题。我第一版图省事,在后端加了 CorsFilter 放开所有跨域,结果本地跑通了,部署到服务器后反而出现一堆奇怪问题,后来把方案改成了前后端分离的标准做法:开发环境下用 Vite 的 proxy 代理转发,生产环境用 Nginx 反向代理。
Vite 配置如下:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true }, '/upload': { target: 'http://localhost:8080', changeOrigin: true } } }这样前端请求 /api/xxx 会自动转发到后端 8080 端口,前端代码里全部使用相对路径,而不是写死 IP 地址。生产部署时,Nginx 配置把 /api 和 /upload 都转发到后端服务,静态文件由 Nginx 直接托管,速度和稳定性都比后端托静态文件好。
6.2 图片路径与打包部署的坑
图片路径是联调阶段最容易出问题的地方。开发环境下,前端通过代理访问后端上传目录,图片 URL 相对路径可以直接使用。部署到生产环境后,需要确保 Nginx 对 /upload 路径的请求指向服务器的磁盘目录。我见过很多同学在本地跑得好好的,部署后图片全裂,就是没配置这个映射。
另一个典型坑是 Vue Router 的 history 模式。开发时一切正常,打包部署后直接刷新某个子页面会出现 404,原因是服务器没有配置回退到 index.html。解决方案有两种:简单方案是把路由模式改成 hash,刷新不会 404;标准方案是在 Nginx 配置里加 try_files 指令指向 index.html。考虑到毕设答辩时通常没有独立运维环境,为了稳妥我建议直接用 hash 模式,功能完全一样,不会因为部署环境不同而翻车。
6.3 数据库版本与 Java 版本匹配问题
数据库这块,MySQL 5.7 和 MySQL 8.0 的驱动配置有明显区别。如果你用的是 MySQL 8.0,驱动类是 com.mysql.cj.jdbc.Driver,同时连接 URL 需要带 useSSL=false 和 serverTimezone=Asia/Shanghai,否则会报时区错误。SpringBoot 项目里一般在 application.yml 配置数据源,示例如下:
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/missing_person?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456Java 版本方面,Spring Boot 2.x 配合 Java 8 最稳妥,如果用了更高版本的 Spring Boot,要注意 JDK 版本兼容性。实体类里时间字段建议用 LocalDateTime,和 MySQL 的 datetime 类型直接对应,不要用 java.util.Date,否则 MyBatis Plus 做查询和序列化时容易出幺蛾子。
6.4 分页插件与 SQL 日志的配合
MyBatis Plus 的 PaginationInnerInterceptor 分页插件是必须配置的,不配的话分页查询不会生效。我建议在开发阶段同时开启 SQL 日志,可以看到 MyBatis Plus 实际执行的 SQL,排查条件拼接问题会非常高效。日志配置在 application.yml 里设置 mapper 包的日志级别为 debug,答辩时还可以顺手展示你的 SQL 调优意识。
7. 把现成代码变成“自己的毕设”:改造路线与答辩准备
7.1 改名换皮与结构梳理清单
拿到一套现成源码,第一件事不是急着跑起来,而是先全局搜索替换项目名。这步如果偷懒,答辩时老师看到代码里到处是别人的项目名,印象分会大打折扣。我整理了一个改造清单:
- 全局替换前端项目名、页面标题、Logo 文案、浏览器标签页标题;
- 全局替换后端包名、应用名、数据库名;
- 重设前端主题色,Element Plus 可以通过 CSS 变量覆盖主色调,让界面换个风格;
- 清理代码里的测试数据和写死的演示账号,自己重新建一套初始数据;
- 修改 SQL 初始化脚本中的默认管理员密码,用 BCrypt 加密后写入。
换皮之后,花两天时间把每条业务链路从头到尾走一遍,记录下各模块的字段和逻辑,做到“即使断网也能默写核心表结构”。这是答辩底气的主要来源。
7.2 性价比最高的功能扩展
如果时间富余,我建议优先做三个扩展点,题图正好都有对应位置。
第一个是统计看板。用 ECharts 做一个六个月的登记趋势折线图和一个当前状态分布的饼图,后端写两个聚合 SQL 接口。这个扩展工作量小、视觉效果好,老师一眼能看到你的系统有数据可视化能力。
第二个是 Excel 导出。引入 EasyExcel,在后台信息管理列表加一个“导出当前筛选结果”的按钮,导出字段需要单独建一个 VO 类,把 status 数字映射成中文状态。这个功能实现简单,但很能体现工程细节,也比导出全表更合理。
第三个是公告缓存。引入 Redis 缓存门户公告列表,后台更新公告时删除缓存。答辩时讲到这个点,可以顺带说明 Redis 的缓存策略和失效机制,展示你对性能优化的理解。
7.3 答辩必问问题与应对思路
答辩老师问的问题其实高度相似,提前把答案想清楚比现场临场发挥可靠得多。我把自己被问过和看到别人被问过的高频问题整理成了一张表:
| 高频问题 | 回答思路 |
|---|---|
| 为什么用 JWT 而不是 Session? | JWT 无状态、适合前后端分离,服务端不需要保存会话信息;Session 在多服务器部署时同步困难 |
| 为什么用 MyBatis Plus? | 单表 CRUD 不用手写 SQL,提高开发效率;复杂查询仍然可以写 XML,灵活性不受影响 |
| 状态字段为什么用数字不用字符串? | 节省存储空间,查询排序方便;配合常量枚举避免魔法值,代码可读性更高 |
| 模糊查询用 LIKE 效率低怎么办? | 毕设数据量小,索引足够;未来数据量大可考虑全文索引或搜索引擎,属于应用层的扩展方向 |
| 如何防止 SQL 注入? | MyBatis Plus 基于预编译机制,参数不会直接拼接到 SQL;同时后端对输入字段做了长度和类型校验 |
| 图片上传有什么安全隐患? | 限制文件类型和大小,重命名文件防止路径穿越,不在数据库中存文件本身只存路径 |
回答这些问题时有一个技巧:回答完“是什么”之后,立刻补一句“当时我权衡过另外的方案,但因为某些原因选了当前这个”。这个“对比过”的动作本身就体现了工程思维,比背标准答案有用得多。
7.4 拿源码做毕设时的临门一脚
最后分享一个比较实用的小习惯:在交付前,把整个项目的启动流程从头模拟一遍,从克隆代码、创建数据库、导入初始化 SQL、配置数据源、启动后端、启动前端,到最终打开页面跑通核心流程。把每一步遇到的问题和解决方式记下来,写成一篇简短的开发笔记。这样做有三个好处:第一,论文的“系统测试”章节有了真实素材;第二,答辩被问到部署细节时不容易卡壳;第三,老师如果要求现场演示,你也不会因为环境问题当场翻车。
我从选这个题目到最终答辩通过,最大的感受是:一套源码的价值不在于代码本身,而在于你能否把每个模块为什么这样设计讲清楚。数据库表结构的字段取舍、状态流转的边界控制、前后端交互的数据格式,这些想明白了,代码跑不跑得通都是次要的。如果你正卡在选课设或毕设题目的环节,希望这篇能给你一个比较完整的参照系,照着这个结构去理解手里那套 SpringBoot + Vue 的源码,比漫无目的刷视频教程要快得多。