☰
SpringBoot+Vue3+MyBatis实战:失踪人员信息发布与管理系统设计与实现
2026/10/6 19:07:07 网站建设 项目流程

1. 项目定位:失踪人员信息发布与管理系统到底在做什么

做这个失踪人员信息发布与管理系统,最初是因为一个做公益寻人的朋友找到了我。他们团队一直在微信群里靠人工转发寻人启事,信息散落在各个聊天记录和朋友圈里,有人提供线索了也只能靠手工记录,核对的效率非常低。更麻烦的是,寻人启事一旦过期,信息就沉底了,再要找回来几乎不可能。聊了几轮之后,我决定用Java SpringBoot+Vue3+MyBatis这一套前后端分离的方案,帮他们搭一个真正能跑起来的系统。项目源码交付出来后,我把它分享到了技术社区,没想到不少做公益组织、救助站、甚至社区警务辅助系统的朋友都来问细节,所以干脆把整个设计和实现过程整理成这篇博文。

这个系统核心解决的问题其实就三件事:第一,让失踪人员信息有结构化的录入入口,而不是散落在聊天记录里;第二,让寻人启事能被高效检索和展示,方便快速扩散;第三,让社会公众提供的线索能统一收集、管理、核对,形成闭环。系统本身不复杂,但麻雀虽小五脏俱全,涉及用户权限、信息审核、图片上传、条件检索、线索管理等典型功能,非常适合用来学习SpringBoot+Vue3前后端分离项目的完整开发流程。

如果你是一个正在学Java全栈的开发者,或者你需要给某个组织快速搭建一个信息发布与管理系统,这篇文章会很对你有用。我会从需求拆解、技术选型、数据库设计、核心接口实现、前端页面开发到部署排坑,一条线讲清楚。文章里所有代码都是实际跑通过的,没有删减。我尽量把每一步的“为什么这么做”也讲明白,因为光看代码是学不会设计的。

2. 需求拆解与功能边界划定

动手写代码之前,我花了整整两天做需求梳理。这个系统的用户角色其实很清晰,就三类:系统管理员、信息录入员(可以理解为志愿者)、普通访客(比如看到寻人启事来提供线索的热心人)。三类角色对应的需求侧重点完全不同,这直接决定了权限模型的设计。

2.1 核心角色与权限模型

访客端的核心诉求是快速浏览寻人启事、按条件筛选、查看详情,以及提交线索。访客不需要注册登录,因为寻人平台要降低公众参与门槛,一旦要求注册,很多热心人就不愿意填了。所以访客走的是公开访问路径,接口只开放只读权限,加上线索提交。

录入员是平台上使用频率最高的角色,他们的日常工作就是登记失踪人员信息、上传照片、修改信息、处理自己录入的信息。录入员需要登录,但权限范围限制在本人创建的数据上,不能看到其他人录入的信息草稿。这就避免了志愿者之间互相干扰。

管理员主要做三件事:审核录入员提交的信息、管理所有已发布信息(下架、编辑、标记已找到)、查看线索列表并处理线索。管理员对所有数据有完全权限,同时可以管理录入员账号。整个权限模型我最终用RBAC(基于角色的访问控制)做了三张表:用户表、角色表、用户角色关联表,没有做按钮级权限,因为这类公益系统根本不需要那么重,角色到接口层面控制就足够了。

2.2 功能模块划分

按照上面的角色梳理,整个系统拆成了六个功能模块:

  • 信息登记模块:录入员填写失踪人姓名、性别、年龄、身高、体貌特征、失踪时间、失踪地点、联系人、联系电话、照片等。这里有一个关键设计——失踪时间默认取当前时间,因为实际场景中很多信息是家属事后才来登记的,但是录的时候往往记不清准确时间了,所以表单上我加了一个“是否精确到日”的开关,如果关闭,时间只存年月。
  • 信息审核模块:录入员提交的寻人信息默认状态是“待审核”,不会在前台公开展示。管理员审核通过后才对外发布。这个机制非常重要,寻人信息的准确性直接影响公众信任度,乱七八糟的信息一旦放出去,整个平台的可信度就毁了。
  • 信息发布与展示模块:前端首页展示审核通过的信息,支持按性别、年龄段、失踪地区、失踪时间范围组合筛选,支持关键词搜索(姓名、特征)。列表是分页加载的,卡片式布局,每条信息展示照片、姓名、年龄、失踪时间和地点。
  • 线索管理模块:访客在详情页看到寻人信息后,可以提交线索,比如“我某月某日在某地见过类似的人”。线索提交后会直接关联到对应寻人信息下,管理员和录入员可以在后台看到线索列表,并标记线索状态(待核实、已核实、无效)。
  • 寻人公告管理模块:管理员可以发布寻人进展公告,比如“某某已找到”,公告会展示在详情页顶部。同时信息会被标记为“已找到”,从首页的“寻找中”列表自动移出。
  • 统计与数据导出模块:管理员可以查看平台数据概览:总信息数、已找到人数、待审核数、线索总数。数据导出做成Excel,方便组织做月度报告。

这些模块看起来多,但实际上每个模块的CRUD逻辑都很标准,难度主要集中在信息审核的状态流转、线索与寻人信息的关联查询、以及图片上传处理这三个点上。接下来我会一个个展开讲。

3. 技术选型:为什么是SpringBoot+Vue3+MyBatis+MySQL

这套技术栈放在今天不算新奇,但在选型时我是认真对比过的,不是随便拼凑。下面把每个组件的取舍逻辑说清楚,方便你做技术选型时参考。

3.1 后端选型:SpringBoot与MyBatis

SpringBoot已经是Java后端开发的事实标准,这一点没什么争议。我选SpringBoot的一个重要原因是它和Spring Security结合做登录鉴权非常顺,而MyBatis则是我故意选的。为什么不选MyBatis-Plus?我承认Plus在单表CRUD上确实省事,但失踪人员信息查询往往需要多条件动态拼接SQL、多表关联统计,在MyBatis里写XML控制SQL会更直观,排查问题也更方便。特别是线索按时间段、地区、信息状态多维组合筛选的时候,动态SQL用<if>标签拼起来,SQL执行计划自己心里有数。用Plus虽然代码少,但一旦业务复杂起来,直观性会打折扣。

我用的SpringBoot版本是2.7.18,这个版本是2.x分支的最终版,稳定性和兼容性都有保障。为什么不直接上SpringBoot 3.x?因为3.x基于Jakarta命名空间,部分老版本的MyBatis Start、其他三方库需要同步升级,我们项目里还需要对接一些内部工具,用2.7.18会省掉不少兼容性折腾。如果你是全新项目、没有历史包袱,直接上3.x也没问题,但注意用新版配套的mybatis-spring-boot-starter。Java环境我用的是JDK 8配合SpringBoot 2.7.18,这个组合在绝大多数服务器上都能跑,部署成本最低。

3.2 前端选型:Vue3 + Vite + Element Plus

前端技术栈选择Vue3是完全基于生态现状的判断。Vue3的组合式API(Composition API)写业务逻辑比Vue2的选项式API清晰很多,一个寻人信息表单涉及十几个字段、联动校验和动态图片列表,用组合式API组织代码能把“单一职责”体现得很直观。

构建工具我选了Vite。Vite在开发环境下基于原生ESM,冷启动速度吊打Webpack,项目里几十个组件秒开。生产构建用Rollup,打包优化也不用操太多心。这套组合在2026年的今天依然是Vue3生态的主流方案,无论是学习还是生产使用都不过时。

UI组件库用了Element Plus。寻人系统是面向公众的,页面不需要花哨,但信息展示要清晰、表单操作要顺手。Element Plus在表格、表单、分页、弹窗、消息提示这些场景下非常成熟,而且和Vue3契合度高。特别是照片上传组件,我直接基于el-upload封装了一层,支持图片预览和删除,省了很多事。

3.3 数据库选型:MySQL

MySQL在中小型应用里依然是性价比最高的选择,没有之一。这个系统的数据量级撑死到十万级记录,MySQL加合适的索引完全够用。我选了MySQL 5.7的InnoDB引擎,因为5.7在事务支持、行级锁、崩溃恢复方面已经非常成熟,而且很多云服务器默认预装的就是5.7或者8.0。逻辑备份用mysqldump,物理备份用binlog,运维简单直接。字符集我统一用了utf8mb4,因为寻人信息里可能包含生僻字、特殊符号,utf8mb4才能完整覆盖。排序规则用了utf8mb4_general_ci,如果用utf8mb4_unicode_ci在部分MySQL版本下对中文排序会有一些奇怪的行为,所以保守起见选了前者。

3.4 前后端分离的架构收益

前后端分离现在已经是标配,我这一套也不例外。后端只提供RESTful API,前端完全独立部署,静态资源丢到Nginx就能跑。这样做有几个实际好处:

  • 部署环境隔离:后端API可以部署在云服务器,前端静态页可以挂CDN,流量高峰时不会互相拖累。
  • 团队并行开发:前后端各有一套代码库,后端定义好接口文档后,前端同学甚至可以用Mock数据先把页面写出来,不用等后端接口就绪。
  • 多端复用:我后端接口全部是无状态的,只靠Token鉴权,以后如果要做一个微信小程序版本,直接复用同一套API,不用改后端逻辑。

架构上我画了一幅分层图,但这里用文字描述:浏览器访问Vue3前端页面,前端通过Axios调用后端API;后端Controller层接收请求,Service层处理业务逻辑,Mapper层通过MyBatis操作MySQL数据库;跨域通过CORS配置解决;登录用JWT做无状态鉴权。这套链路清晰、好排查问题。

4. 数据库表设计:核心是状态流转与关联查询

数据库是这个系统的地基。表设计得好不好,直接决定后面写动态SQL时是优雅拼接还是痛苦嵌套。我把核心表的结构分享出来,并说明每个字段的思考过程。

4.1 失踪人员信息表(missing_person)

这是整个系统的核心表。字段设计如下:

字段名类型说明
idBIGINT(20)主键,自增
nameVARCHAR(50)失踪人姓名
genderTINYINT性别:1男,2女,0未知
ageINT失踪时年龄
heightINT身高(厘米),允许为空
body_featuresVARCHAR(500)体貌特征描述
missing_dateDATE失踪日期
missing_locationVARCHAR(200)失踪地点
contact_nameVARCHAR(50)联系人姓名
contact_phoneVARCHAR(20)联系人电话
photo_urlVARCHAR(500)照片地址,支持多张,用逗号分隔
statusTINYINT状态:0待审核,1已发布,2已找到,3已下架
remarkVARCHAR(500)备注信息
create_byBIGINT(20)创建人用户ID
create_timeDATETIME创建时间
update_timeDATETIME更新时间

几个关键设计点:

**photo_url为什么用逗号分隔字符串而不建子表?**这是一个典型的取舍。照片如果有多张,规范做法是建一张照片子表,通过missing_person_id关联。但我实际调研后发现,寻人启事绝大多数情况就一到三张照片,用子表会产生大量无关紧要的JOIN和冗余的ORM映射。我最终用逗号分隔存在一个字段里,前端拿到后split(',')渲染成多图预览。如果以后照片数量可能超过五张,我建议还是拆子表,现在这个量级这个方案足够。

**status字段为什么用TINYINT而不直接用字符串枚举?**存储上TINYINT省空间,查询上MySQL对整数索引效率更高。业务状态含义我统一放在后端常量类里管理,前端通过接口获取状态字典,而不是硬编码在页面里。这样后续如果新增状态(比如“复核中”),前端不用改代码。

4.2 线索表(clue_info)

线索是“公众参与”的载体,设计线索表的重点是如何高效关联到寻人信息、如何跟踪线索处理状态。

字段名类型说明
idBIGINT(20)主键
missing_person_idBIGINT(20)关联的失踪人员ID
clue_contentTEXT线索内容
clue_timeDATETIME目睹时间
clue_locationVARCHAR(200)目睹地点
reporter_nameVARCHAR(50)线索提供人姓名
reporter_phoneVARCHAR(20)线索提供人联系方式
statusTINYINT状态:0待核实,1已核实,2无效
remarkVARCHAR(500)管理员备注
create_timeDATETIME提交时间

线索表的索引设计是我精心考虑过的。missing_person_id字段必须有索引,因为后台按寻人信息查看线索是最高频的操作。create_time也建了索引,因为管理员经常按时间倒序看最新线索。status字段单独建索引的意义不大,因为它区分度太低,和missing_person_id或者create_time做联合索引才有效。

4.3 用户表与角色表

用户表字段比较常规:id、username、password(BCrypt加密后存储)、real_name(真实姓名)、phone、avatar、status(启用/禁用)、create_time。密码绝对不允许明文存储,必须加密。我用的是BCryptPasswordEncoder,每次校验时调用matches方法验证,即使数据库泄露,密码也无法反向还原。

角色表我就设了两种角色:ADMIN和OPERATOR(录入员)。前面说的访客不需要建账号。用户角色关联表(user_role)记录用户和角色的映射关系。这个设计虽然比直接在用户表存一个role字段多了一张表,但扩展性更好,以后如果增加“审核员”角色,只需要在关联表里插入记录,不用动用户表结构。

4.4 审核状态流转

这条状态流转线是整个业务的命脉:录入员创建信息时status=0(待审核)→ 管理员审核通过后status=1(已发布)→ 寻人成功后管理员或录入员将状态改为2(已找到)→ 出现异常时下架状态改为3(已下架)。状态流转规则必须在后端Service层强校验。比如录入员无权直接把“待审核”改成“已发布”,如果从前端直接调接口传status=1,后端必须拦截。我实现了一个简单的枚举类封装状态值,并在更新方法里校验当前状态和目标状态的合法性。这样做可以有效避免绕过前端页面直接调接口的操作。

5. 后端核心接口与MyBatis实现细节

后端代码结构我按常见的分层来组织:controller、service、mapper、entity、dto、config。这里不打算把所有代码贴出来,重点项目讲核心接口的实现逻辑和SQL写法。

5.1 寻人信息分页检索接口

这是整个系统最核心的接口之一。访客首页要通过条件筛选和关键词搜索找到目标寻人信息。接口设计为GET请求,参数包括:pageNum、pageSize、keyword(匹配姓名和体貌特征)、gender、ageMin、ageMax、province、city、startDate、endDate。返回值封装为分页对象:总记录数、总页数、当前页数据列表。

关键难点在多条件动态拼接SQL。我用的MyBatis动态标签来实现。实际写法如下:

<select id="pageList" resultType="com.example.entity.MissingPerson"> SELECT * FROM missing_person <where> status = 1 <if test="keyword != null and keyword != ''"> AND (name LIKE CONCAT('%', #{keyword}, '%') OR body_features LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="gender != null"> AND gender = #{gender} </if> <if test="ageMin != null"> AND age &gt;= #{ageMin} </if> <if test="ageMax != null"> AND age &lt;= #{ageMax} </if> <if test="startDate != null"> AND missing_date &gt;= #{startDate} </if> <if test="endDate != null"> AND missing_date &lt;= #{endDate} </if> </where> ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} </select>

注意几个细节:

为什么用<where>标签而不是手写WHERE 1=1?<where>标签会自动处理多余的AND关键字,SQL日志看起来更干净,避免“1=1”这种丑写法引发审查麻烦。

为什么用&gt;=、&lt;=而不是直接写>=、<=?XML文件中<和>是特殊字符,直接写会导致XML解析错误。>=可以不用转义,但为了统一规范,我全部用了实体写法。这是一处非常容易踩的坑,新手经常在这里被报错卡住半天。

LIMIT #{offset}, #{pageSize}用于分页。我没有引入PageHelper插件,因为这个系统分页逻辑简单,手写LIMIT足够,少一个依赖就少一分配置风险。数据量大到几十万级的时候,LIMIT深分页(比如翻到第200页)会变慢,到时候可以改成基于游标的方案,但目前这个量级不需要。

5.2 线索提交与去重逻辑

访客提交线索的接口很容易被恶意刷,我在Service层做了三重防护:

第一,同一IP限制:记录IP地址,同一个IP在10分钟内只能提交5条线索,超过则拒绝。这个逻辑我用Redis实现,但如果你项目里没有Redis,用内存Map加过期时间也能凑合(单机部署情况下)。

第二,同手机号限制:一个手机号在24小时内针对同一条寻人信息只能提交一次线索。实现方式是查库,先SELECT COUNT(*)判断,再执行插入。在高并发下可能有竞态问题,但公益平台线索量有限,加一个唯一索引uk_missing_reporter (missing_person_id, reporter_phone)从数据库层面兜底,这才是最可靠的方案。

第三,内容长度校验:线索内容不得小于10个字,不得包含网址链接。这个是为了防止灌水和垃圾广告,正则表达式过滤掉http://和https://开头的文本。

5.3 MyBatis关联查询与懒加载

在后台列表页,管理员需要同时看到寻人信息的基本情况、当前状态、以及收到的线索数。这个线索COUNT不完全适合单独拉一个接口在前端循环调用,那样会产生N+1问题——列表20条数据就要额外发20次查询,严重影响性能。我直接在SQL里用子查询实现:

<select id="adminList" resultType="com.example.dto.MissingPersonVO"> SELECT mp.*, (SELECT COUNT(*) FROM clue_info WHERE missing_person_id = mp.id) AS clue_count FROM missing_person mp <where> <if test="status != null"> AND mp.status = #{status} </if> </where> ORDER BY mp.create_time DESC LIMIT #{offset}, #{pageSize} </select>

这是典型的“以空间换时间”思路,一条主查询搞定,不用循环调用。对于列表展示类业务,能用子查询解决的尽量不要用ORM的懒加载,懒加载在循环里触发会非常致命。

5.4 JWT登录鉴权实现

登录流程用的是JWT无状态方案。用户输入用户名密码,后端校验通过后生成一个Token返回给前端。Token中包含用户ID和角色信息,并设置7天有效期。前端每次请求时在Header里带上Authorization: Bearer <token>,后端通过拦截器统一解析Token,如果过期或非法直接返回401。

Spring Security我用了但是简化配置,没有引入一堆过滤器链。核心做法是:

@Configuration @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() .cors().and() .authorizeRequests() .antMatchers("/api/public/**", "/api/auth/login", "/api/missing/page").permitAll() .antMatchers("/api/admin/**").hasRole("ADMIN") .antMatchers("/api/operator/**").hasRole("OPERATOR") .anyRequest().authenticated(); } }

然后我写了一个JwtAuthenticationFilter,继承OncePerRequestFilter,在每个请求进来时解析Token并放入SecurityContext。这部分代码比较长,核心逻辑就是解析JWT,如果拿到了用户ID,查出用户信息,设置认证态。注意,接口路径权限必须配置在后端,前端的路由守卫只能算作体验优化,不能作为安全边界。

6. Vue3前端实现:从零搭建到页面交付

前端部分我用Vite从零搭建项目,没有用vue-cli,因为Vite在开发体验上确实好很多。下面按步骤讲清楚整个前端项目的搭建过程和核心代码。

6.1 项目初始化与基础配置

初始化命令很简单:

npm create vite@latest missing-person-web -- --template vue cd missing-person-web npm install

装完基础依赖后,需要额外安装以下几个包:

npm install vue-router@4 pinia axios element-plus sass

其中状态管理选了Pinia,它是Vue3官方推荐的状态库,比Vuex用法更简洁,支持组合式API直接写业务逻辑。写了几年Vuex的人上手Pinia基本无障碍,而且不需要写那么多mutations,同步状态直接改$state,很方便。

后续归档:我利用vue-router配置了三个主要的视图:首页(HomeView.vue)、详情页(DetailView.vue)、管理后台(AdminView.vue)。管理后台内部又包含几个子路由:信息管理、线索管理、用户管理、数据统计。整体用<router-view>嵌套渲染,侧边栏菜单用Element Plus的el-menu实现。

6.2 Axios二次封装与拦截器

Axios封装是每个前端项目的第一道工序,我习惯把所有请求细节都收到一个文件里管理。核心代码如下:

import axios from 'axios' import { ElMessage } from 'element-plus' const service = axios.create({ baseURL: '/api', timeout: 15000 }) // 请求拦截器:自动带token 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) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response && error.response.status === 401) { ElMessage.error('登录已过期,请重新登录') localStorage.removeItem('token') location.href = '/login' } else { ElMessage.error(error.message || '网络异常') } return Promise.reject(error) } ) export default service

这里有一个很有用的细节:开发环境下我通过Vite的代理配置解决跨域问题,在vite.config.js中添加:

export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })

这样前端所有请求都走相对路径/api/xxx,由Vite开发服务器代理到后端。生产环境则直接把后端的接口路径和前端静态文件一起部署到同一个域名下,也不存在跨域问题。这是一个非常省心的策略,开发生产都不用为了CORS头疼。

6.3 组合式API组织寻人信息表单

这里重点讲一个前端核心业务——寻人信息登记表单。这个表单字段多、校验规则多,用Vue3的组合式API组织非常合适。

// 在组件中组织表单逻辑 const formRef = ref(null) const formData = reactive({ name: '', gender: null, age: null, height: null, body_features: '', missing_date: '', missing_location: '', contact_name: '', contact_phone: '', photoList: [], status: 0 }) const rules = { name: [{ required: true, message: '请输入姓名', trigger: 'blur' }], gender: [{ required: true, message: '请选择性别', trigger: 'change' }], missing_date: [{ required: true, message: '请选择失踪日期', trigger: 'change' }], missing_location: [{ required: true, message: '请输入失踪地点', trigger: 'blur' }], contact_phone: [ { required: true, message: '请输入联系电话', trigger: 'blur' }, { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' } ] } const submitForm = async () => { await formRef.value.validate() const payload = { ...formData, photo_url: formData.photoList.map(item => item.url).join(',') } await api.submitMissingPerson(payload) ElMessage.success('提交成功,等待管理员审核') resetForm() }

照片上传组件这里多说两句。我用el-upload的http-request属性自定义了上传行为,因为Element Plus默认的上传行为和我们的业务需求不完全匹配:

const uploadPhoto = async (options) => { const formData = new FormData() formData.append('file', options.file) const res = await api.uploadFile(formData) if (res.code === 200) { formData.photoList.push({ url: res.data.url }) ElMessage.success('照片上传成功') } }

上传接口由后端处理,文件流通过MultipartFile接收,重命名后存储在服务器本地指定目录,同时生成访问URL。我在这里把文件名改成UUID就是为了避免中文文件名和重复名导致的问题。

7. 项目部署与几个不得不踩的坑

项目写完不算完,能顺利部署上线才算真正结束。我在这里把部署步骤和踩过的坑记录一下,帮你省去不必要的折腾。

7.1 前后端打包部署流程

后端打包很简单,在项目根目录执行:

mvn clean package -DskipTests

打包完成后,在target目录下会生成一个xxx.jar文件。部署时我建议用java -jar直接启动,同时配合nohup做后台运行:

nohup java -jar missing-person-system-1.0.0.jar --spring.profiles.active=prod &

生产环境的数据库连接、文件上传路径、日志级别都要放到application-prod.yml里,不要直接改默认配置。前端构建命令是:

npm run build

构建完成后dist目录下就是纯静态文件,把它上传到服务器上,用Nginx指向这个目录即可。

Nginx配置里两个要点:一是前端路由使用history模式时,必须配置try_files回退到index.html,否则直接刷新二级页面会404;二是把/api路径反向代理到后端Java进程端口。完整配置片段如下:

server { listen 80; server_name your-domain.com; root /opt/missing-person-web/dist; index index.html; location / { try_files $uri $uri/ /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; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }

7.2 图片上传目录的读写权限问题

Linux服务器上部署时经常遇到的一个问题:上传图片后页面访问报403或无法显示。原因绝大多数是上传目录没有写权限或者Nginx用户没有读权限。我的解决思路:在服务器上创建/data/upload目录,然后:

chmod -R 755 /data/upload

同时把SpringBoot的配置指向这个目录:

file: upload-dir: /data/upload access-prefix: /upload/**

然后Nginx需要配置一个静态映射,让/upload/**开头的请求直接指向/data/upload目录:

location /upload/ { alias /data/upload/; }

这里有个经验之谈:不要图省事把图片交给后端Java处理访问路径,直接用Nginx静态文件服务性能是最好的。通过Java IO流去读磁盘文件再输出响应,性能和稳定性都要差一些。

7.3 MySQL连接参数的坑

MySQL连接串的配置看似简单,里面藏着一个很实际的坑。如果用默认的时区参数,serverTimezone=Asia/Shanghai必须显式配置,否则Java程序连接MySQL 8.0以上版本时会报错。5.7版本会宽松一些,但也建议显式加上。另外,连接池参数也很关键,我实际配置如下:

spring: datasource: url: jdbc:mysql://localhost:3306/missing_person_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: xxxxxx hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000

useSSL=false很重要,如果MySQL端没有配置SSL证书,这个参数不设会导致连接警告甚至失败。局域网部署场景下,SSL加密是可有可无的,直接关掉节省握手开销。

7.4 日志查看与线上问题排查

线上排查问题,日志就是你的眼睛。我配置了Logback滚动日志,按天生成文件,保留30天:

logging: file: name: /data/logs/missing-person-system.log level: com.example.missingperson.mapper: DEBUG

这里特别说明,Mapper包级别Debug日志会把MyBatis的SQL和参数打印出来,线上排查“查不到数据”或者“数据不对”时这是最直接的线索。但注意生产环境不建议长期开DEBUG,日志量会暴涨,只在排查问题时临时开启。

我踩过的经典坑之一是“明明重启了项目但查询结果还是旧的”。后来定位到是MyBatis一级缓存和二级缓存的问题。MyBatis的本地缓存特性在同一个SqlSession下会缓存查询结果,如果项目中某个环节复用了SqlSession,拿到的是缓存数据。虽然在Spring整合环境下每次请求都会新建SqlSession,默认一级缓存基本不会造成问题,但一旦你开了二级缓存,又没清楚对应缓存的失效条件,就会遇到脏数据问题。我的原则是:这个系统里直接关闭二级缓存,要缓存就上Redis,不要依赖MyBatis的二级缓存机制。

7.5 前后端时间格式统一

最后说一个非常容易阴沟翻船的问题:时间格式。Java后端返回的LocalDateTime默认序列化格式是2026-01-15T10:30:00,这个带T的格式前端展示起来非常难看,而且用户不习惯。我在后端做了全局Jackson配置:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai

前端接收日期字符串后直接展示,不需要再做格式化。但注意,如果用LocalDate序列化,上面这个配置不生效,需要在字段上加@JsonFormat(pattern = "yyyy-MM-dd")注解。这两个坑我都踩过,写在这里希望你能少花半天时间。

8. 项目实战心得:这套系统还能怎么升级

系统主体跑通后,我后来又做了几轮迭代优化,也想清楚了这个项目后续可以怎么扩展。这里简单讲讲我个人的思路。

第一个优化点是引入Elasticsearch做全文检索。现在MySQL的LIKE '%keyword%'在数据量小的时候完全没问题,但如果未来数据量增长到几十万甚至百万条,这个查询会明显变慢。ES可以做到更快的全文检索和联想搜索,甚至“根据体貌特征反查相似人员”。不过引入ES也意味着架构复杂度明显上升,不是每个公益组织都有运维能力。按目前绝大多数用户的数据量来看,MySQL加索引完全够用,这个优化属于“储备方案”而不是“需求”。

第二个优化点是增加“相似人员比对”功能。失踪人员特征(身高、年龄、体型、失踪地点周边区域)可以做成多维度向量,利用机器学习做相似度匹配。比如有人报案称在某地见到一个疑似走失人员,系统可以自动从数据库中推荐“可能匹配”的失踪人员,大幅提升寻人效率。这个功能如果要做,后端架构需要引入向量数据库或使用Milvus,成本不低,但公益价值很大。我当时没有实施,主要原因是缺少足够的标注数据做模型训练,而且这种功能的误报率如果高,反而会误导志愿者精力。

第三个优化点是增加公众线索的微信小程序入口。现在前端是PC端网页,公众提交线索路径太长了。小程序作为入口更符合公益场景——用户看到寻人启事,直接在小程序里拍照上传线索,使用门槛极低。由于后端接口本身是前后端分离的,复用度很高,小程序端只需要重新写一层前端即可。

最后说一句我的体会:这类系统技术难度不在极致的性能优化,而在于把信息流转的状态关系理清楚,把权限边界守好,把公众侧的体验做简单。技术栈选得再潮,如果数据不准、状态混乱、界面难用,都白搭。按照SpringBoot后端提供稳定接口、Vue3前端聚焦交互体验、MyBatis精确控制SQL这一套组合,在开发效率和可维护性之间取得了比较好的平衡。这篇文章里的所有代码片段和技术决策都来自真实落地项目,你照着做至少能少走两三个弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询