先说个真实的情况。上周有个读者发来一份“校园失物招领系统”的毕设代码,让我帮忙看看为什么启动就报错。我打开之后发现两个非常典型的问题:前端还是 JSP + jQuery 的老写法,后端虽然用了 SpringBoot,但数据表设计把“丢失物品”和“拾到物品”塞在了一张表里,认领关系用一个 varchar 字段存 JSON 字符串。这种代码不能说不能用,但它停留在“能跑”的阶段,离“能交给学校、能上线给人用”还有很大距离。
这也是我写这篇基于 SpringBoot + Vue + MyBatis + MySQL 的校园失物招领系统实战笔记的初衷。我不会去贴一个所谓的“2025最新源码包”——那种资源十有八九是下载下来就藏木马,或者表结构一塌糊涂。我更想把一套真正可落地的设计思路、建表方案、后端接口、前端页面和部署踩坑记录完整写出来。无论你是要做课程设计、毕业设计,还是想自己搭一个校内服务型系统,照着这份思路去改、去扩展,都比拿一份来路不明的源码香得多。
整个项目我会按“需求分析 → 技术选型 → 数据库建模 → 后端实现 → 前端实现 → 部署避坑 → 复盘扩展”这条链路来讲,全程是实际开发中的做法,不是教科书式的伪代码。
1. 需求拆解:失物招领系统不只是简单 CRUD
1.1 业务角色与状态流转
校园失物招领这个场景,核心参与角色有三个:丢东西的人、捡到东西的人、后台管理员。大多数源码只把这几个角色做成“能登录、能发帖、能删帖”,这忽略了最关键的东西——状态流转。
我先说一个判断一个失物招领系统是否合格的标准:它必须能回答三个问题。
- 我现在丢的东西,有没有可能被人捡到?
- 我现在捡到的东西,有没有人来认领?认领过程是否可信?
- 某个失物或失物认领,当前到底走到哪一步了?
如果系统里只有一个 is_found 布尔字段,这些问题全都回答不了。我在设计时把状态拆成了三组:
- 失物状态:
SEARCHING(寻找中)、MATCHED(已找到)、CLOSED(已关闭) - 招领状态:
PENDING(待认领)、AUDITING(认领审核中)、COMPLETED(已完成)、OFFLINE(已下架) - 认领状态:
WAITING(待审核)、APPROVED(审核通过)、REJECTED(已拒绝)、FINISHED(已领取)
你可能注意到了,“认领审核中”这个状态很多系统会漏掉。真实的业务场景是:一个学生提交认领申请,管理员要先核对特征证明,然后联系拾主确认,最后才让学生来取。这一整套流程如果只靠线下微信沟通,系统就成了摆设。把状态机设计进去,每个环节都有时间戳和处理人,后续追责、回溯都会有据可查。
1.2 用户视角的功能地图
用户角色不同,看到的核心功能也不同。我整理了一张功能地图,这也是后面建表和写接口的依据。
| 角色 | 核心功能 | 典型页面 |
|---|---|---|
| 普通用户(学生) | 注册登录、发布寻物信息、发布招领信息、提交认领申请、查看我的发布与申请记录 | 首页列表、发布表单、详情页、个人中心 |
| 管理员 | 用户管理、失物信息审核、招领信息审核、认领申请处理、公告发布、基础统计 | 后台仪表盘、审核列表、认领处理页 |
| 游客 | 浏览公开的失物/招领信息、搜索 | 首页列表、搜索页 |
很多人会问,失物信息也需要审核吗?我的建议是“招领必审,失物抽审”。招领信息涉及陌生人之间的接触,一旦被恶意发布很容易引发安全问题;而失物信息是寻物方主动发布的,通常不存在隐私骗取风险,管理员只要对明显违规的内容做下架处理就够了。
1.3 MVP 边界:第一版别做“智能匹配”
做项目最容易犯的错,就是第一版想太多。自动匹配遗失物与招领物听起来很高级,但实际上你很难定义“相似”的判定标准:同样是校园卡,校区、学号、姓名都要比对,做错了只会让用户对系统失去信心。
第一版我建议只做:搜索、分类筛选、发布、详情、认领申请、后台审核。搜索先做成标题+描述的关键词模糊匹配就够用了。自动匹配可以作为第二期功能,在后台手动置顶“最近失物”,或者按分类做简单推荐即可。先把闭环跑通,再谈智能化,这是我一直坚持的边界原则。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型:SpringBoot+Vue+MyBatis+MySQL 这套组合为什么经得起用
2.1 为什么从 SSM 换到 SpringBoot
很多老项目还在用 SSM(Spring + SpringMVC + MyBatis),配置繁琐是最大的痛点。你想想,光是一个 spring-mvc.xml 就要写组件扫描、视图解析器、拦截器、静态资源映射,稍微写错一个命名空间就启动失败。SpringBoot 用自动配置把这套东西全收编了,starter 机制加依赖、内嵌 Tomcat 一键启动、application.yml 统一配置,开发效率完全不在一个级别。
2025 年做新项目,我建议直接用 Spring Boot 3.2.x + JDK 17,而不是还在用 2.7.x。理由很简单:新版框架的生态会逐步向 Jakarta EE 迁移,很多依赖只维护新版本,你现在用旧版,再过两年想升级会遇到一堆兼容问题。当然,如果你们实验室或学校电脑只装了 JDK 8,那老老实实退回 Spring Boot 2.7.18,不要纠结,旧版照样能跑。
2.2 Vue 3 + Vite 带来的前后端分离体验
前端为什么选 Vue?因为它能做单页应用,部署后就是纯静态文件,拷到 Nginx 或 SpringBoot 的静态目录里就能跑,不需要服务端渲染模板。Vue 3 + Vite 的构建速度比 Vue 2 + Webpack 快一个数量级,ESM 开发模式下热更新几乎是瞬时的。
这套失物招领系统用 Vue 3 非常合适,页面以列表和表单为主,组件封装起来重用性很高。比如“发布失物”和“发布招领”两个页面,表单在七八成相似,完全可以抽一个公共的 ItemForm.vue,通过传入 type 区分场景。这比复制一份页面再改字段名优雅得多。
2.3 MyBatis 的 SQL 可控性是这类系统的加分项
有一个问题经常被问到:既然都 2025 年了,为什么不用 MyBatis-Plus 或 JPA?
我的回答是:MyBatis-Plus 确实能提升开发效率,但如果你是在做毕设或课程设计,评审老师更看重你对 SQL 的理解。而且失物招领系统里“分页搜索 + 动态条件拼接”是高频场景,MyBatis 的 XML 动态 SQL 可以把条件逻辑写得非常清晰,可控性远好于 JPA 的自动生成的 SQL。
顺便说一句,很多人觉得 MyBatis XML 没有代码提示,不好写。我建议装一个 MyBatisX 插件(IDEA 里的那个),它可以自动在 Mapper 接口和 XML 之间跳转,还能给 XML 里的 SQL 做语法高亮,体验会好很多。
2.4 MySQL 8.x、字符集与连接池
数据库选择 MySQL 8.0+,主要原因是它默认字符集已经是 utf8mb4,对中文、emoji 表情、生僻字都能正确存储。很多老项目用 utf8,导致用户昵称里放个 emoji 直接插入失败。
连接池方面,SpringBoot 默认的 HikariCP 是当前综合表现最好的连接池,不需要额外引依赖。真正要注意的是连接池大小,失物招领这类校园小程序并发量不大,maximumPoolSize 设置在 10~20 就足够,堆太多了反而白白占用 MySQL 连接资源。
3. 数据库建模:五张核心表撑起整个业务闭环
3.1 完整建表语句
失物招领系统不需要复杂的业务表,真正核心的只有五张:用户表、失物表、招领表、认领记录表、公告表。下面是我整理的可直接落地的建表 SQL。
sql复制CREATE DATABASE IF NOT EXISTS lost_found DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
USE lost_found;
CREATE TABLE `lost_found_user` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`username` VARCHAR(50) NOT NULL COMMENT '登录名',
`password` VARCHAR(100) NOT NULL COMMENT 'BCrypt加密后的密码',
`real_name` VARCHAR(50) DEFAULT NULL COMMENT '真实姓名',
`student_no` VARCHAR(30) DEFAULT NULL COMMENT '学号',
`phone` VARCHAR(20) DEFAULT NULL COMMENT '联系电话,非脱敏存储',
`avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像URL',
`role` VARCHAR(20) NOT NULL DEFAULT 'USER' COMMENT '角色:USER/ADMIN',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1正常 0禁用',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
CREATE TABLE `lost_item` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` BIGINT NOT NULL COMMENT '发布人ID',
`title` VARCHAR(100) NOT NULL COMMENT '标题',
`description` TEXT COMMENT '物品详细描述',
`category` VARCHAR(30) NOT NULL COMMENT '分类:校园卡/证件/电子产品/书籍/其他',
`lost_location` VARCHAR(100) DEFAULT NULL COMMENT '丢失地点',
`lost_date` DATE DEFAULT NULL COMMENT '丢失日期',
`images` VARCHAR(2000) DEFAULT NULL COMMENT '图片URL,多个用逗号分隔',
`contact` VARCHAR(100) DEFAULT NULL COMMENT '对外联系方式',
`status` VARCHAR(20) NOT NULL DEFAULT 'SEARCHING' COMMENT '状态:SEARCHING/MATCHED/CLOSED',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_category_time` (`category`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='失物表';
CREATE TABLE `found_item` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` BIGINT NOT NULL COMMENT '发布人ID',
`title` VARCHAR(100) NOT NULL COMMENT '标题',
`description` TEXT COMMENT '物品详细描述',
`category` VARCHAR(30) NOT NULL COMMENT '分类',
`found_location` VARCHAR(100) DEFAULT NULL COMMENT '拾到地点',
`found_date` DATE DEFAULT NULL COMMENT '拾到日期',
`images` VARCHAR(2000) DEFAULT NULL COMMENT '图片URL',
`keeper` VARCHAR(100) DEFAULT NULL COMMENT '暂存处/保管人',
`contact` VARCHAR(100) DEFAULT NULL COMMENT '对外联系方式',
`status` VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING/AUDITING/COMPLETED/OFFLINE',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_category_time` (`category`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='招领表';
CREATE TABLE `claim_record` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`found_item_id` BIGINT NOT NULL COMMENT '要认领的招领信息ID',
`lost_item_id` BIGINT DEFAULT NULL COMMENT '关联的失物信息ID,可为空',
`user_id` BIGINT NOT NULL COMMENT '认领人ID',
`reason` VARCHAR(500) DEFAULT NULL COMMENT '认领理由',
`proof` VARCHAR(500) DEFAULT NULL COMMENT '特征证明,如校园卡后四位、物品特殊标记',
`proof_images` VARCHAR(2000) DEFAULT NULL COMMENT '证明图片URL',
`status` VARCHAR(20) NOT NULL DEFAULT 'WAITING' COMMENT '状态:WAITING/APPROVED/REJECTED/FINISHED',
`handler_id` BIGINT DEFAULT NULL COMMENT '处理人管理员ID',
`handle_remark` VARCHAR(500) DEFAULT NULL COMMENT '处理备注',
`apply_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '申请时间',
`handle_time` DATETIME DEFAULT NULL COMMENT '处理时间',
PRIMARY KEY (`id`),
KEY `idx_found_item_id` (`found_item_id`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='认领记录表';
CREATE TABLE `notice` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`title` VARCHAR(100) NOT NULL,
`content` TEXT NOT NULL,
`type` VARCHAR(20) NOT NULL DEFAULT 'NOTICE' COMMENT '类型:NOTICE/HELP',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公告表';
这个设计里有一个容易被忽略的细节:claim_record 表同时关联了 found_item_id 和 lost_item_id。因为在真实场景中,用户可能先看到招领信息、再来认领(所以关联招领ID),也可能先发布了失物信息、管理员或系统帮他匹配到了招领信息(所以关联失物ID)。把两个 ID 都保留,后面做统计和匹配会非常方便。
3.2 索引设计的取舍
失物招领系统的核心查询模式是“按分类 + 按关键词 + 按时间倒序”去刷列表,所以我在建表时加了 idx_category_time(category, create_time) 这个联合索引,能够支撑常见的分类浏览场景。
但要注意,LIKE '%关键词%' 这种前导通配符的查询不会走索引。这是 MySQL 自身的限制,不是设计缺陷。如果你非要优化这个场景,可以引入 Elasticsearch,但显然校园失物招领这个量级没必要。老老实实用 CONCAT('%', #{keyword}, '%') 去模糊匹配就可以了。
3.3 联系方式的隐私存储
最后说隐私。用户的手机号在数据库里要存完整,这没有争议,因为管理员核实时需要看到全量信息。但接口返回给前端时,手机号必须脱敏。我的做法是后端在查询结果进行 VO 转换时,把 phone 字段中间四位替换成 *。只有管理员角色的接口才返回完整手机号。
这种处理方式成本很低,但能避免很多麻烦。很多人说“我用的是校园网,不会有外部人看到”,你把脱敏字段返回去了,截图传到表白墙、QQ群,一样泄露隐私。
4. 后端落地:SpringBoot 分层与 MyBatis 实战
4.1 工程结构与通用返回体
后端我采用的是最常规的分层方式:Controller 接收请求、Service 处理业务、Mapper 访问数据库。对于失物招领这种中小型系统,不需要过度设计 DDD 那套,分层清晰、职责单一就够了。
text复制lost-found-server
├── src/main/java/com/example/lostfound
│ ├── config
│ │ ├── WebMvcConfig.java // 静态资源映射与拦截器注册
│ │ └── CorsConfig.java // 跨域配置
│ ├── controller
│ │ ├── AuthController.java
│ │ ├── LostItemController.java
│ │ ├── FoundItemController.java
│ │ ├── ClaimController.java
│ │ └── AdminController.java
│ ├── entity
│ │ ├── LostItem.java
│ │ ├── FoundItem.java
│ │ ├── ClaimRecord.java
│ │ ├── User.java
│ │ └── Notice.java
│ ├── mapper
│ │ ├── LostItemMapper.java
│ │ ├── FoundItemMapper.java
│ │ └── ...
│ ├── service
│ │ ├── LostItemService.java
│ │ └── impl/LostItemServiceImpl.java
│ ├── common
│ │ ├── Result.java
│ │ ├── JwtUtil.java
│ │ └── GlobalExceptionHandler.java
│ └── utils
│ └── PhoneUtil.java
├── src/main/resources
│ ├── application.yml
│ └── mapper
│ ├── LostItemMapper.xml
│ └── FoundItemMapper.xml
├── sql
│ ├── 01_schema.sql
│ └── 02_init_data.sql
└── pom.xml
通用返回体是每个接口的统一信封,我写成这样:
java复制public class Result<T> {
private Integer code;
private String message;
private T data;
public static <T> Result<T> success(T data) {
return new Result<>(200, "success", data);
}
public static <T> Result<T> success() {
return new Result<>(200, "success", null);
}
public static <T> Result<T> error(String message) {
return new Result<>(500, message, null);
}
public static <T> Result<T> error(Integer code, String message) {
return new Result<>(code, message, null);
}
// 构造方法和 getter/setter 略
}
有了这个统一返回体,前端 axios 拦截器才能做到“只看 code、不管 HTTP status”。
4.2 配置文件里的几个关键点
application.yml 是 SpringBoot 项目中值得反复检查的文件。下面是失物招领项目的核心配置。
yaml复制server:
port: 8080
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/lost_found?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: 123456
hikari:
maximum-pool-size: 10
minimum-idle: 5
servlet:
multipart:
max-file-size: 10MB
max-request-size: 20MB
mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.example.lostfound.entity
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
重点解释几个点:
serverTimezone=Asia/Shanghai必须有,不然 MySQL 8.x 会因为本地时区与服务器时区不一致,在时间字段上出现 8 小时偏移。allowPublicKeyRetrieval=true是 MySQL 8 的认证缓存相关配置,不加上去连接时可能报Public Key Retrieval is not allowed。map-underscore-to-camel-case: true决定了数据库的create_time能不能自动映射到实体的createTime。很多人查出来时间是 null,八成就是忘了开这个配置。log-impl: StdOutImpl用于在控制台打印 SQL,开发阶段建议开,上线前记得关闭,不然日志量很大。
4.3 登录鉴权:JWT + BCrypt
登录这块我没有用 session,而是选 JWT,因为前后端分离下 JWT 天然适配无状态场景。用户注册时密码用 BCrypt 加密,登录成功后后端返回一个 token,前端后续每次请求都在请求头里带 Authorization: Bearer <token>。
java复制@Component
public class JwtUtil {
private final SecretKey key = Keys.hmacShaKeyFor("lost-found-secret-key-2025-2025".getBytes());
public String generateToken(Long userId, String role) {
return Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("role", role)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 7))
.signWith(key)
.compact();
}
public Claims parseToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
}
我额外写了一个 LoginInterceptor,在 preHandle 里校验 token。管理员接口则在 Controller 方法上打一个 @RequireRole("ADMIN") 注解,由拦截器做二次校验。如果你不想引入注解切面,也可以简单地在管理员 Controller 里手动判断 JwtUtil.parseToken 返回的 role 字段。哪种都行,重点是:后端一定要校验角色,不能只靠前端藏按钮。
4.4 失物发布的完整链路
以“发布失物”这个接口为例,完整走一遍从 Controller 到 Mapper 的链路,你就会明白这套代码为什么更好维护。
java复制@RestController
@RequestMapping("/api/lost")
public class LostItemController {
@Resource
private LostItemService lostItemService;
@PostMapping
public Result<Long> createLostItem(@RequestBody @Validated LostItemForm form) {
Long id = lostItemService.createLostItem(form);
return Result.success(id);
}
}
表单校验用 JSR 303 注解:标题必填、分类必填、丢失日期不能晚于今天。很多人会把校验全部堆在 Service 里,代码就越写越臃肿,用注解全部挡在入口处是最省事的。
Service 里的业务逻辑主要为:
java复制@Override
public Long createLostItem(LostItemForm form) {
LostItem item = new LostItem();
item.setUserId(form.getUserId());
item.setTitle(form.getTitle());
item.setDescription(form.getDescription());
item.setCategory(form.getCategory());
item.setLostLocation(form.getLostLocation());
item.setLostDate(form.getLostDate());
// 图片数组转为逗号分隔字符串
item.setImages(form.getImages() == null ? null : String.join(",", form.getImages()));
// 对外联系方式:默认用用户注册手机号,防止用户忘记填写
item.setContact(StringUtils.hasText(form.getContact()) ? form.getContact() : userService.getById(form.getUserId()).getPhone());
item.setStatus("SEARCHING");
lostItemMapper.insert(item);
return item.getId();
}
这里有一个我踩过坑后才加上的逻辑:联系方式如果不传,就自动取当前用户的注册手机号。因为发布者很容易漏填联系方式,而一个没有联系方式的失物招领,基本等于废信息。
Mapper 层我直接在注解里写了简化版 insert,XML 里则放的是分页搜索这种复杂查询。下图是复杂查询的 XML 片段:
xml复制<select id="selectPageByCondition" resultType="LostItem">
SELECT * FROM lost_item
<where>
<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}, '%'))
</if>
</where>
ORDER BY create_time DESC
</select>
注意搜索条件的写法。第一,必须用 #{keyword},不要用 ${keyword}。因为 #{} 会走预编译,而 ${} 是字符串拼接,直接往 SQL 里怼,百分百存在注入风险。第二,模糊匹配用 CONCAT,不要试图在代码层把 % 套到变量上再传进来,那样会让 mapper 接口的可读性大打折扣。
4.5 认领申请与管理员审核流程
认领申请是整个系统业务含金量最高的地方。学生点击“我要认领”之后,后端要同时做三件事:校验招领信息状态必须是 PENDING、校验当前用户不是发布者本人、插入一条 claim_record 记录且状态为 WAITING。
java复制@Override
public Long createClaim(ClaimForm form) {
FoundItem foundItem = foundItemMapper.selectById(form.getFoundItemId());
if (foundItem == null) {
throw new BusinessException("招领信息不存在");
}
if (!"PENDING".equals(foundItem.getStatus())) {
throw new BusinessException("该物品当前不能认领");
}
if (foundItem.getUserId().equals(form.getUserId())) {
throw new BusinessException("不能认领自己发布的招领信息");
}
ClaimRecord record = new ClaimRecord();
record.setFoundItemId(form.getFoundItemId());
record.setLostItemId(form.getLostItemId());
record.setUserId(form.getUserId());
record.setReason(form.getReason());
record.setProof(form.getProof());
record.setStatus("WAITING");
claimRecordMapper.insert(record);
// 同时把招领信息状态改成认领审核中,避免多人同时申请同一件物品
foundItemMapper.updateStatus(form.getFoundItemId(), "AUDITING");
return record.getId();
}
管理员审核时,只做一件事却不简单:把 claim_record 状态改成 APPROVED,把对应 found_item 状态改成 COMPLETED。这两个更新必须在同一个事务里完成,否则会出现认领记录显示已通过,但招领信息还挂着“待认领”的错乱状态。Spring 的 @Transactional 放在 Service 方法上即可。
4.6 文件上传:本地存储还是对象存储
失物招领系统的图片上传,我建议分场景决策。如果是部署在校内服务器的小系统,直接存本地磁盘就够了;如果扩展成多校区,或者有高可用要求,再上 MinIO 或阿里云 OSS。
但无论存哪里,有几个点必须注意。
第一,不要传到项目的工作目录里。很多人图片传到 target/classes/upload,重新打包或者重启后文件就没了。正确做法是固定一个外部绝对路径,比如 Linux 的 /data/lost-found/upload/。
java复制@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
String uploadPath = "/data/lost-found/upload/";
registry.addResourceHandler("/upload/**")
.addResourceLocations("file:" + uploadPath);
}
}
第二,文件名要重写。不要用用户上传时的原始文件名,一方面可能有中文路径问题,另一方面容易产生路径穿越风险。我统一用 UUID.randomUUID().toString().replace("-", "") 生成随机文件名,再拼上原扩展名。
第三,扩展名白名单。可以接收 jpg、jpeg、png、webp,其他一律拒绝。别嫌麻烦,这是防止有人传可执行文件当图片的最基本手段。
5. 前端 Vue 工程:页面、路由与接口对接
5.1 脚手架与依赖安装
前端我用 Vite 创建 Vue 3 工程,命令如下。
bash复制npm create vite@latest lost-found-web -- --template vue
cd lost-found-web
npm install
npm install element-plus axios vue-router
npm run dev
这里我建议把组件库选为 Element Plus。虽然有些人觉得 Element Plus 高端大气不足,但对于校园管理系统,它组件全、表格表单开箱即用,开发效率高,而且社区教程丰富,遇到问题一搜一大把。这个项目里,Element Plus 的 el-form、el-table、el-upload 和 el-pagination 能覆盖 80% 的交互需求。
5.2 路由设计与参数传递
前端路由是整个页面结构的地基。我直接给出关键路由配置。
js复制const routes = [
{ path: '/', name: 'Home', component: () => import('@/views/Home.vue') },
{ path: '/lost/create', name: 'CreateLost', component: () => import('@/views/lost/CreateLost.vue') },
{ path: '/lost/detail/:id', name: 'LostDetail', component: () => import('@/views/lost/LostDetail.vue') },
{ path: '/found/create', name: 'CreateFound', component: () => import('@/views/found/CreateFound.vue') },
{ path: '/found/detail/:id', name: 'FoundDetail', component: () => import('@/views/found/FoundDetail.vue') },
{ path: '/my/items', name: 'MyItems', component: () => import('@/views/user/MyItems.vue') },
{ path: '/admin/audit', name: 'AdminAudit', component: () => import('@/views/admin/Audit.vue') },
];
我强烈建议所有组件都写成路由懒加载的形式,也就是 () => import(...)。失物招领首页和发单页本身都很轻,但懒加载能让首屏打包体积明显变小,这个习惯从第一个项目就养成最好。
关于路由参数,这里有一个容易混淆的细节:动态路径里的 :id 是 params,查询条件是 query。详情页跳转建议用 params,例如 /lost/detail/5;列表页筛选条件建议用 query,例如 /lost?category=电子&page=1。这样做的原因是:详情页参数是唯一标识,用路径形式更利于分享和收藏;筛选条件是可变的,用 query 形式刷新后还能保留。
5.3 Axios 封装与 Token 注入
接口请求统一走一个封装过的 axios 实例,不要在组件里到处裸调 axios.get。
js复制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 = `Bearer ${token}`;
}
return config;
});
request.interceptors.response.use(
res => {
const data = res.data;
if (data.code !== 200) {
ElMessage.error(data.message || '请求失败');
return Promise.reject(new Error(data.message));
}
return data;
},
err => {
if (err.response && err.response.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
}
ElMessage.error('网络错误,请稍后重试');
return Promise.reject(err);
}
);
这里的 baseURL: '/api' 配合 Vite 代理能解决开发环境的跨域问题,具体配置在第 6 节。上线后用 Nginx 做反代,这个路径依然适用,不需要改前端代码。
5.4 列表页与发布表单的实战片段
列表页是失物招领系统权重最高的一个页面。我用 Element Plus 的 el-card 展示卡片式列表,每一张卡片显示图片、标题、分类标签、丢失/拾到地点和发布时间。分页用 el-pagination,查询参数变化时重新请求接口。
发布表单的核心点是图片上传组件 el-upload,它的 action 要指向 /api/common/upload:
vue复制<el-form-item label="物品图片" prop="images">
<el-upload
action="/api/common/upload"
:headers="{ Authorization: `Bearer ${token}` }"
list-type="picture-card"
:on-success="handleUploadSuccess"
:limit="6"
>
<el-icon><Plus /></el-icon>
</el-upload>
</el-form-item>
每个上传成功的回调里,会把接口返回的图片 URL 拼进表单的 images 数组中。绑定表单提交后,后端把数组转成逗号分隔的字符串存库。这里我想提醒一件事:图片上传走的是 multipart 请求,token 必须放在 headers 里而不是 query 参数里,否则后端鉴权拦截器根本读不到 token。
5.5 状态标签的优雅展示
后端状态字段是英文枚举,前端展示必须映射成中文标签和不同颜色。我用一个公共工具文件维护映射关系:
js复制export const LOST_STATUS_MAP = {
SEARCHING: { label: '寻找中', type: 'warning' },
MATCHED: { label: '已找到', type: 'success' },
CLOSED: { label: '已关闭', type: 'info' },
};
export const CLAIM_STATUS_MAP = {
WAITING: { label: '待审核', type: 'warning' },
APPROVED: { label: '审核通过', type: 'success' },
REJECTED: { label: '已拒绝', type: 'danger' },
FINISHED: { label: '已领取', type: 'info' },
};
页面里只需要 CLAIM_STATUS_MAP[item.status].label 就能显示,不需要在每个组件里写 v-if 去判断。这个技巧在多个页面都要展示状态时特别有用,改一处全局生效。
6. 联调部署与踩坑记录
6.1 跨域问题的三种解决方式
前后端分离后,跨域是第一个弹出的“地雷”。开发阶段,我推荐用 Vite 代理解决,避免后端起 CORS 允许所有来源带来的安全性问题。
js复制// vite.config.js
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
});
生产环境如果走 Nginx,也是一段简单的反代配置:
nginx复制location /api/ {
proxy_pass http://localhost:8080/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
如果你后面直接跑 jar,浏览器访问 8080 端口,前端打包后的静态文件在 /static 下,这时同源就不会有跨域问题。三种方案我建议按实际情况选,不要在开发环境一上来就 Access-Control-Allow-Origin: *,后面查生产问题会很痛苦。
6.2 MyBatis 与 MySQL 的常见“坑”
这些坑我在排查别人代码时遇到无数次,非常典型。
第一个是时间字段 8 小时偏移。MySQL 的 DATETIME 不带时区,JDBC 驱动如果不指定 serverTimezone,默认取系统时区。如果你的服务器是 UTC,本地是东八区,查出来的时间就少了 8 小时。解决办法就是连接串加 serverTimezone=Asia/Shanghai。
第二个是下划线转驼峰。实体字段是 createTime,数据库字段是 create_time,如果不开 map-underscore-to-camel-case,你查出来的实体 createTime 永远是 null。加上之后,MyBatis 会自动完成双向映射,XML 里写 SELECT * 就能直接映射到实体。
第三个是 MyBatis 缓存导致的“旧数据”错觉。MyBatis 有一级缓存,存在于同一个 SqlSession 中,当你连续两次查询同一条件时,第二次可能直接返回缓存结果。遇到这类现象,先别急着怀疑 SQL,看一眼事务前后是否复用了同一个 SqlSession。因为在 SpringBoot 里如果方法没加 @Transactional,每次调用 mapper 都是新的 SqlSession,一级缓存基本无效;一旦你用事务包装了一段查询逻辑,缓存就会生效。这种隐式变化容易让人忽略。
第四个是乐观锁问题。认领审核这种并发操作,最好在 claim_record 上加一个 version 字段,更新时带上 version = #{version} 的条件,防止两个管理员同时处理同一单。校园系统并发虽然不高,但加一个乐观锁几乎零成本,值得养成习惯。
6.3 打包整合方案
打包有两种思路,我分别说明。
方案 A(课设推荐):前端构建后把 dist 目录内容复制到后端 src/main/resources/static 下,再统一打成 jar。
bash复制cd lost-found-web
npm run build
cp -r dist/* ../lost-found-server/src/main/resources/static/
cd ../lost-found-server
mvn clean package -DskipTests
java -jar target/lost-found-server-1.0.0.jar
这个方案的好处是部署简单,只要一个 jar 就能跑。缺点是前后端耦合,改前端要重新打后端包。
方案 B(生产推荐):前端打成 dist 放到 Nginx,后端分离部署,用反向代理解决 /api 路径转发。好处是前后端独立升级,缺点是服务器上要多装一个 Nginx。如果你的学校服务器环境比较简单,我建议直接方案 B,因为后续迭代发布都更方便。
6.4 数据库初始化与版本管理
我要强调一个词:数据库版本管理。不要在 Navicat 里手动改完表结构,再导一份 SQL 发给别人。正确做法是按目录保存脚本:
text复制sql/
├── 01_schema.sql
├── 02_init_data.sql
└── 03_update_add_version.sql
发布时按编号顺序执行。哪怕你是在做个人毕设,这个习惯也会让你多一分从容——至少你重装环境时不用从头回忆表结构。
6.5 安全与性能基础
安全方面,密码一定要 BCrypt,不要用 MD5;接口鉴权一定要在拦截器里统一处理,不要在 Controller 方法里重复写校验逻辑。性能方面,列表页记得加 limit 和 offset,不要一次性查 10 万条记录;后台统计页面如果有分组查询,加上合适的索引。失物招领系统的数据量级不会很大,做到这些基础点已经足够了。
7. 项目复盘与可以继续深化的方向
7.1 从课设答辩角度看到的得分点
这套系统做成课设或毕设,答辩时最值得讲的是状态机设计和认领审核流程,而不是“我用了 SpringBoot”。很多同学一上来就讲技术栈,评委听着就想打断。你应该讲:你在什么业务场景下,遇到了什么问题,然后怎么通过表结构和接口设计解决的。
例如你回答“为什么招领信息要设计成 PENDING/AUDITING/COMPLETED/OFFLINE 四态,而不是布尔值”,这就能体现出你不是在堆代码,而是在思考业务。再从技术角度补充“认领事务里如何保证两个更新操作一致性”,整个回答就有了深度。
7.2 从实际运营角度看这套系统
上半年我帮一个学生组织把类似的系统在一个校区跑了大约两个月。数据非常有意思:失物登记数量是招领登记数量的三倍多。说明大部分人捡到东西不会主动登记。这暴露了一个纯粹的被动 CRUD 系统很难解决的问题:用户没有动力来发布招领信息。
后来我们做了一个小改动:把“招领成功”设计成一件有成就感的事,学生发布招领信息后可以获得校园服务积分,失物成功归还后展示“失物归还成功”的标识。这个改动短期内有效果,但长期运营还需要更多激励设计。如果你要把这个系统做成真正的校园服务,这段话值得参考。
7.3 后续可以扩展的五个方向
我会按投入产出比排序,给你五个扩展方向。
- 消息通知:认领状态变化时,通过短信或邮件通知用户,这是刚需。
- 失物-招领相似度提示:用分类 + 地点 + 标题关键词做简单匹配,推荐给管理员审核。
- 校园卡一卡通自动匹配:很多校园卡上有固定前缀或编号规则,可以做成特殊分类。
- 管理端数据看板:展示登记量、找回率、高频丢失地点,为学校后勤提供决策数据。
- 小程序端:Vue 3 项目可以直接使用 uni-app 或 Taro 改造成小程序,触达率更高。
这几个方向优先级我按“通知 > 匹配 > 数据看板 > 小程序”来排。前三个是系统自身能力的提升,小程序是入口的扩展,你根据自身时间安排选择即可。
最后再分享一个小细节。我给这个系统加了一个“实名登记”的软约束:注册时学号和手机号必填,但不对学号真实性做严格校验。一开始我以为会有人乱填,但实际运营下来,绝大多数学生填的信息都是真实的。这个细节让我意识到,校园场景的信用土壤比互联网公域好太多,你在设计审核流程时可以更信任用户,把精力放在异常情况处理上。如果你在开发时也遇到类似的选择,我的建议是:先做最小可行版本,把它放在真实校园环境里跑两个星期,再回来改功能。你得到的反馈,一定比闷头写代码更有价值。
