公司动态
Spring Boot + MyBatis-Plus 实现校园失物招领系统全栈开发教程
校园失物招领系统是校园信息化建设中很典型的一个全栈练习项目。很多人以为它只是“发布物品信息 列表展示”两个增删改查页面真正动手写起来才发现校园失物招领系统要同时处理寻物启事和拾物招领两种信息类型还要承接认领申请、确认认领、状态变更等流程。若一开始不把业务链路设计清楚代码写到一半就会出现字段越来越多、接口越来越乱的问题。本文围绕校园失物招领系统按业务建模、数据库设计、后端接口、前端联调、运行排错五条主线带读者完成一个可以运行的最小闭环。技术栈选用 Spring Boot 3.x MyBatis-Plus MySQL 8前端使用原生 HTML 和 JavaScript 联调避免引入前端构建链路。适合正在准备毕业设计、课程设计或想通过一个具体项目熟悉前后端分离开发流程的读者。文章中的示例代码用于说明思路落地时请结合自己的包名、路径和实际版本做调整。1. 校园失物招领系统的业务链路和状态机先想清楚1.1 三个角色和一条完整业务闭环校园失物招领系统里主要存在三类参与方。失主物品丢失后发布寻物启事或者浏览招领信息并提交认领申请。拾主捡到物品后发布拾物招领等待失主联系和认领并负责确认申请是否匹配。系统管理员在完整版本中负责审核信息、处理虚假发布、删除过期数据。学习项目可以先把管理员放到扩展功能里不影响主流程跑通。一条完整的业务闭环可以表达为拾主发布拾物招领信息 - 失主浏览列表并申请认领 - 拾主查看认领申请并确认 - 线下归还物品 - 信息状态变为已完成。寻物启事和拾物招领并不是两条独立链路而是共享同一套数据结构和状态机。系统真正做的是把两侧信息匹配起来同时用状态字段控制每个动作的合法顺序。这个设计直接决定了数据库结构和接口复杂度。1.2 用状态机约束操作顺序校园失物招领系统的核心是状态机而不是单纯的增删改查。物品类型字段type用来区分信息方向字段值含义发布者角色1寻物启事失主2拾物招领拾主信息状态字段status用来描述一条物品信息当前的进展字段值含义可触发动作0待处理可认领、可关闭1已完成不可再申请认领2已关闭发布者手动关闭例如物品已找回但未走认领流程认领记录也有自己的状态字段status字段值含义使用场景0待确认失主提交认领申请后1已确认拾主确认物品匹配2已拒绝拾主认为描述不匹配这些状态字段不是可有可无的装饰。接口侧所有业务规则都依赖状态判断。例如认领申请提交时如果物品状态不是 0应该直接拒绝信息发布者不能认领自己发布的信息同一用户对同一物品不能重复提交认领申请。如果这些规则散落在前端页面里数据很容易被绕过修改后端必须逐条校验。1.3 学习项目应该做的和可以先不做的功能校园失物招领系统的功能范围很容易失控。建议先做一套满足业务闭环的最小功能集再逐步扩展。最小功能集用户注册和登录。发布寻物启事。发布拾物招领。按类型、关键词分页查询失物信息。查看信息详情。失主提交认领申请。拾主查看并确认认领申请。发布者手动关闭信息。可以放到第二阶段的功能管理员后台信息审核和删除。图片上传和对象存储。站内消息通知。失物招领数据统计报表。小程序客户端。不建议在第一版实现的功能如 OCR 物品识别、人脸识别、AI 自动匹配。这些方向虽然听起来加分但会占用大量时间且基线版本搭建不完整时后续排错成本很高。注意项目功能边界要写在开发文档里否则很容易被“顺手加个功能”的思路带偏。学习项目更应该先完成闭环再谈扩展。2. 数据库建模三张表如何支撑整个认领流程2.1 先拆解数据关系再设计表结构整个系统最核心的数据关系是三张表用户表、物品信息表、认领记录表。用户表存储账号信息和基本联系方式。物品信息表同时存寻物启事和拾物招领通过type字段区分。认领记录表保存每次认领申请的过程避免把多个申请堆在物品表里。物品表和用户表的关系是发布关系publisher_id指向用户表主键。认领记录表同时关联物品和用户item_id指向物品表user_id指向认领人。这里采用逻辑外键而不是数据库物理外键目的是减少写操作时的约束校验同时方便后续分库分表。逻辑外键需要在应用层保证数据一致性。2.2 建表 SQL 示例以下 SQL 在 MySQL 8 环境下验证通过字符集使用utf8mb4避免中文和特殊符号乱码。CREATE TABLE user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 登录用户名, password varchar(100) NOT NULL COMMENT 加密后的密码, phone varchar(20) DEFAULT NULL COMMENT 联系电话, student_no varchar(30) DEFAULT NULL COMMENT 学号可选, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE item ( id bigint NOT NULL AUTO_INCREMENT, type tinyint NOT NULL COMMENT 1-寻物启事 2-拾物招领, title varchar(100) NOT NULL COMMENT 标题, content text COMMENT 详细描述, category varchar(50) DEFAULT NULL COMMENT 物品分类如证件/电子产品/书籍, place varchar(100) DEFAULT NULL COMMENT 丢失或捡到地点, happen_time datetime DEFAULT NULL COMMENT 丢失或捡到时间, image_url varchar(255) DEFAULT NULL COMMENT 图片地址, status tinyint NOT NULL DEFAULT 0 COMMENT 0-待处理 1-已完成 2-已关闭, publisher_id bigint NOT NULL COMMENT 发布者ID, view_count int NOT NULL DEFAULT 0 COMMENT 浏览量, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_type_status (type, status), KEY idx_publisher_id (publisher_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT失物/招领信息表; CREATE TABLE claim_record ( id bigint NOT NULL AUTO_INCREMENT, item_id bigint NOT NULL COMMENT 物品信息ID, user_id bigint NOT NULL COMMENT 认领人ID, content varchar(255) NOT NULL COMMENT 物品特征描述用于证明归属, status tinyint NOT NULL DEFAULT 0 COMMENT 0-待确认 1-已确认 2-已拒绝, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_item_id (item_id), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT认领申请表;2.3 几个关键设计点第一为什么用一张item表存两种类型而不是拆成lost_item和found_item两张表。失物和招领信息的字段几乎完全一致拆表会造成重复字段和重复代码后续匹配查询还需要做表关联或 union 操作复杂度更高。用type字段区分是最经济的方式。如果未来两种类型字段差异很大再考虑拆表当前阶段不必要。第二status字段为什么用整数而不是字符串。“待处理、已完成、已关闭”这类状态如果直接存中文数据库空间浪费且容易写错如果存英文单词修改时还需要考虑大小写。整数状态配合代码常量最稳定。在后端可以定义常量类public class ItemStatus { public static final int PENDING 0; public static final int FINISHED 1; public static final int CLOSED 2; } public class ItemType { public static final int LOST 1; public static final int FOUND 2; } public class ClaimStatus { public static final int WAITING 0; public static final int CONFIRMED 1; public static final int REJECTED 2; }第三联合索引idx_type_status支撑最常见的查询场景按类型筛选后再按状态约束。如果表中的数据量继续增长还可以把create_time加入索引末尾缓解“按类型 未结束状态 时间倒序”的查询压力。3. 后端接口实现用 Spring Boot MyBatis-Plus 跑通核心闭环3.1 环境准备开发校园失物招领系统前建议先准备好以下环境。组件建议版本范围用途JDK17 或更高版本Spring Boot 3.x 要求 JDK 17Maven3.6 以上依赖管理和构建MySQL8.0 及以上数据存储IDEIntelliJ IDEA 或 Eclipse开发调试前端浏览器Chrome / Edge页面联调和接口验证实际开发前先确认 JDK 和 Spring Boot 版本互相匹配。如果使用 Spring Boot 2.xJDK 版本要求会更低pom.xml 依赖也需要对应调整。3.2 项目目录结构创建一个名为campus-lost的 Spring Boot 项目目录结构如下campus-lost/ ├── pom.xml └── src/main/ ├── java/com/example/campuslost/ │ ├── CampusLostApplication.java │ ├── common/ │ │ ├── Result.java │ │ ├── BusinessException.java │ │ └── GlobalExceptionHandler.java │ ├── config/ │ │ ├── WebConfig.java │ │ └── AuthInterceptor.java │ ├── controller/ │ │ ├── UserController.java │ │ └── ItemController.java │ ├── entity/ │ │ ├── User.java │ │ ├── Item.java │ │ └── ClaimRecord.java │ ├── mapper/ │ │ ├── UserMapper.java │ │ ├── ItemMapper.java │ │ └── ClaimRecordMapper.java │ ├── service/ │ │ ├── UserService.java │ │ ├── UserServiceImpl.java │ │ ├── ItemService.java │ │ └── ItemServiceImpl.java │ └── dto/ │ ├── PublishItemRequest.java │ └── ClaimRequest.java └── resources/ └── application.yml这个结构里dto包存放接口入参对象common包存放统一返回和异常类。对课设项目来说结构不需要过于复杂但控制器、服务、数据访问三层要分开方便后续替换实现。3.3 Maven 依赖和配置文件pom.xml 中引入 Web、MyBatis-Plus、MySQL 驱动和密码加密依赖。以下是核心部分parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.5/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.security/groupId artifactIdspring-security-crypto/artifactId /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies版本号在实际项目中要以官方发布为准。MyBatis-Plus 3.5.x 对不同 Spring Boot 版本的兼容性不同建议先搜索当前项目使用的 Spring Boot 版本对应的 MyBatis-Plus 版本。application.yml 中配置数据源和 MyBatis-Plusserver: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/campus_lost?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root servlet: multipart: max-file-size: 5MB max-request-size: 20MB mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case开启后数据库字段publisher_id可以自动映射到实体字段publisherId。如果不开查询结果会出现字段全部为 null 的情况。3.4 实体类和 Mapper用 MyBatis-Plus 的实体注解简化对象关系映射。以Item实体为例Data TableName(item) public class Item { TableId(type IdType.AUTO) private Long id; private Integer type; private String title; private String content; private String category; private String place; private LocalDateTime happenTime; private String imageUrl; private Integer status; private Long publisherId; private Integer viewCount; private LocalDateTime createTime; private LocalDateTime updateTime; }对应的 Mapper 接口只需要继承BaseMapper不需要手写 XMLMapper public interface ItemMapper extends BaseMapperItem { }UserMapper和ClaimRecordMapper写法相同。MyBatis-Plus 会为每个 BaseMapper 提供selectById、insert、selectPage等通用方法适合学习项目快速开发。3.5 注册登录接口注册接口的核心是密码加密。密码不能明文存库使用BCryptPasswordEncoder加密RestController RequestMapping(/api/user) public class UserController { Resource private UserService userService; PostMapping(/register) public ResultLong register(RequestBody RegisterRequest request) { Long userId userService.register(request); return Result.success(userId); } PostMapping(/login) public ResultLong login(RequestBody LoginRequest request) { Long userId userService.login(request.getUsername(), request.getPassword()); return Result.success(userId); } }简化后的UserServiceImpl中注册逻辑如下Service public class UserServiceImpl implements UserService { Resource private UserMapper userMapper; private final PasswordEncoder passwordEncoder new BCryptPasswordEncoder(); Override public Long register(RegisterRequest request) { Long count userMapper.selectCount(new LambdaQueryWrapperUser() .eq(User::getUsername, request.getUsername())); if (count 0) { throw new BusinessException(用户名已存在); } User user new User(); user.setUsername(request.getUsername()); user.setPassword(passwordEncoder.encode(request.getPassword())); user.setPhone(request.getPhone()); user.setStudentNo(request.getStudentNo()); userMapper.insert(user); return user.getId(); } }登录态在这个演示版本中用请求头X-User-Id传递前端登录后把 userId 存到 localStorage接口请求时自动带上。这个方案用于课设演示是足够的但生产环境必须换成 JWT、Session 或 Spring Security 等完整认证方案。3.6 发布、列表、认领核心接口发布接口接收type、title、content、category、place、happenTime等参数。服务层要做类型校验因为 1 和 2 代表两个不同方向写错会导致信息展示到错误分类PostMapping(/api/item/publish) public ResultLong publish(RequestBody PublishItemRequest request, RequestAttribute(userId) Long userId) { if (request.getType() ! ItemType.LOST request.getType() ! ItemType.FOUND) { throw new BusinessException(物品类型不合法); } Item item new Item(); item.setType(request.getType()); item.setTitle(request.getTitle()); item.setContent(request.getContent()); item.setCategory(request.getCategory()); item.setPlace(request.getPlace()); item.setHappenTime(request.getHappenTime()); item.setImageUrl(request.getImageUrl()); item.setStatus(ItemStatus.PENDING); item.setPublisherId(userId); itemMapper.insert(item); return Result.success(item.getId()); }列表查询使用 MyBatis-Plus 的分页能力需要注意分页插件配置。在 Spring Boot 3.x 中配置方式如下Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor paginationInterceptor new PaginationInnerInterceptor(DbType.MYSQL); paginationInterceptor.setMaxLimit(100L); interceptor.addInnerInterceptor(paginationInterceptor); return interceptor; } }列表接口GetMapping(/api/item/list) public ResultPageItem list(RequestParam(defaultValue 1) Integer page, RequestParam(defaultValue 10) Integer size, RequestParam(required false) Integer type, RequestParam(required false) String keyword) { PageItem pageParam new Page(page, size); LambdaQueryWrapperItem wrapper new LambdaQueryWrapper(); if (type ! null) { wrapper.eq(Item::getType, type); } if (StringUtils.hasText(keyword)) { wrapper.and(w - w.like(Item::getTitle, keyword) .or().like(Item::getContent, keyword)); } wrapper.orderByDesc(Item::getCreateTime); itemMapper.selectPage(pageParam, wrapper); return Result.success(pageParam); }认领申请是失物招领系统最重要的业务动作。写入前必须检查物品存在、状态为待处理、不能认领自己发布的信息、不能重复申请PostMapping(/api/item/claim) public ResultVoid claim(RequestBody ClaimRequest request, RequestAttribute(userId) Long userId) { Item item itemMapper.selectById(request.getItemId()); if (item null) { throw new BusinessException(物品信息不存在); } if (item.getStatus() ! ItemStatus.PENDING) { throw new BusinessException(该物品当前不可认领); } if (item.getPublisherId().equals(userId)) { throw new BusinessException(不能认领自己发布的信息); } Long exists claimRecordMapper.selectCount(new LambdaQueryWrapperClaimRecord() .eq(ClaimRecord::getItemId, request.getItemId()) .eq(ClaimRecord::getUserId, userId)); if (exists 0) { throw new BusinessException(已经提交过认领申请); } ClaimRecord record new ClaimRecord(); record.setItemId(request.getItemId()); record.setUserId(userId); record.setContent(request.getContent()); record.setStatus(ClaimStatus.WAITING); claimRecordMapper.insert(record); return Result.success(); }确认认领接口的权限校验比认领申请更严格必须确保当前登录用户是物品发布者PostMapping(/api/item/confirm) public ResultVoid confirm(RequestParam Long claimId, RequestAttribute(userId) Long userId) { ClaimRecord record claimRecordMapper.selectById(claimId); if (record null) { throw new BusinessException(认领申请不存在); } Item item itemMapper.selectById(record.getItemId()); if (item null || !item.getPublisherId().equals(userId)) { throw new BusinessException(无权操作该认领申请); } if (item.getStatus() ! ItemStatus.PENDING) { throw new BusinessException(物品状态已变更); } record.setStatus(ClaimStatus.CONFIRMED); claimRecordMapper.updateById(record); item.setStatus(ItemStatus.FINISHED); itemMapper.updateById(item); return Result.success(); }这套接口逻辑把状态流转放在事务中保证认领记录和物品状态不会出现一半成功一半失败。生产环境需要在 service 层的方法上增加Transactional注解。4. 前端页面联调不需要复杂框架也能演示完整流程4.1 页面拆分和接口对应关系前端页面使用原生 HTML JavaScript fetch 实现。不需要安装 Node 环境后端启动后直接用浏览器打开 HTML 文件配合允许跨域的配置即可联调。页面对应接口核心功能login.htmlPOST /api/user/login登录保存 userIdregister.htmlPOST /api/user/register注册账号index.htmlGET /api/item/list分页展示失物和招领信息publish.htmlPOST /api/item/publish发布寻物或招领detail.htmlGET /api/item/detail/{id}查看详情并处理认领后端需要在WebConfig里允许前端跨域请求。学习项目可以直接放开生产环境必须限制域名Configuration public class WebConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .maxAge(3600); } }4.2 列表页的实现思路index.html的核心逻辑是读取当前页面的筛选项调用GET /api/item/list接口后渲染列表。fetch 请求示例async function loadItems(page 1, type 0, keyword ) { const params new URLSearchParams({ page, size: 10 }); if (type 0) { params.append(type, type); } if (keyword) { params.append(keyword, keyword); } const resp await fetch(/api/item/list?${params.toString()}); const result await resp.json(); if (result.code ! 0) { alert(result.message); return; } renderList(result.data.records); }后端统一返回格式Result中code 0表示成功非 0 表示失败。前端判断code后决定是渲染数据还是弹出错误提示。这里的接口地址在实际项目中要按部署环境拼接完整地址例如http://localhost:8080/api/item/list。4.3 发布页和详情页的关键点发布页需要让用户选择类型。页面上的“寻物启事”和“拾物招领”两个选项提交时映射到type的 1 和 2。提交前做基本非空校验async function publishItem(data) { const resp await fetch(/api/item/publish, { method: POST, headers: { Content-Type: application/json, X-User-Id: localStorage.getItem(userId) || }, body: JSON.stringify(data) }); const result await resp.json(); if (result.code 0) { location.href index.html; } else { alert(result.message); } }详情页需要展示物品信息并根据当前用户身份显示不同操作按钮。如果当前用户是发布者显示认领申请列表和确认按钮如果是普通用户显示“提交认领申请”按钮和输入框。这种“根据身份显示不同界面”的交互是失物招领系统前端最值得练习的地方。5. 运行验证与排查从启动报错到接口异常的处理路径5.1 启动顺序和验证方法完整启动校园失物招领系统的步骤建议按以下顺序执行。创建数据库campus_lost执行第 2 章的建表 SQL。修改 application.yml 中的数据库账号密码。启动 Spring Boot 应用观察控制台日志出现Started CampusLostApplication。在浏览器访问http://localhost:8080/api/item/list?page1size10预期返回 JSON 结构。打开前端页面注册账号后登录再发布一条测试数据。验证接口时可以使用 curl 直接调用。发布一条拾物招领的测试请求curl -X POST http://localhost:8080/api/item/publish \ -H Content-Type: application/json \ -H X-User-Id: 1 \ -d { type: 2, title: 在图书馆二楼捡到一张校园卡, content: 卡片上写着学号位置请联系认领, category: 证件, place: 图书馆二楼, happenTime: 2025-06-01T10:00:00 }正常情况下返回结果包含新增记录的 id。接着调用列表接口应该能查到这条数据。到这里发布和查询主链路已经跑通。5.2 频繁出现的四类问题问题现象常见原因检查方式处理建议数据库连接报错数据库未启动、账号密码错误、URL 时区配置缺失检查 MySQL 服务状态和 application.yml确认连接串包含serverTimezoneAsia/Shanghai查询返回字段全为 null未开启驼峰映射或实体字段与列名不一致查看控制台 SQL 日志开启map-underscore-to-camel-case或检查实体注解分页接口无数据未配置分页插件或参数为空查看 SQL 日志是否带 LIMIT配置MybatisPlusInterceptor分页拦截器前端跨域报错未配置 CORS 或配置了但不生效打开浏览器控制台查看请求失败原因在WebConfig中配置 allowedOriginPatterns 和 methods5.3 异常日志定位到具体代码行后端出现 500 错误时不要只盯返回值。先在控制台找异常堆栈中的Caused by部分这一行通常直接指向根因。例如Caused by: java.sql.SQLException: Unknown database campus_lost说明数据库没有创建执行建表 SQL 即可。再比如Caused by: org.apache.ibatis.binding.BindingException: Invalid bound statement说明 Mapper 接口没有被扫描到检查启动类上的MapperScan注解或 Mapper 接口上的Mapper注解。注意不要只验证后端能启动还要验证发布、查询、认领申请、确认认领整条流程。许多课设项目在答辩现场出问题都是因为只演示了列表接口没有提前走一遍完整状态流转。6. 最佳实践与扩展方向课设项目如何升级成可维护系统6.1 学习环境与生产环境的主要差异校园失物招领系统从课设水平进入生产水平需要补齐几个关键点。密码存储必须使用 BCrypt 或其他加密算法不能明文入库。演示版本使用X-User-Id请求头传递登录态生产环境应该改成 JWT 方案并在拦截器中校验令牌有效性和过期时间。缺少认证会让任何用户都能调用发布和确认接口。图片处理需要单独讲一点。演示项目可以在本地磁盘保存图片并通过静态资源映射访问。生产环境建议使用对象存储服务后端只保存返回的 URL。上传接口还需要限制文件大小、文件类型并对文件重命名避免路径穿越等安全问题。日志和监控也是生产环境必须考虑的部分。至少要把日志写入文件并按照日期滚动。业务关键动作如发布、认领申请、确认认领都要打日志记录操作人 ID 和物品 ID。6.2 扩展方向的优先级学习项目跑通后可以按以下顺序扩展。消息通知认领申请提交后通知物品发布者确认后通知认领人。管理员功能增加信息审核、违规处理、数据统计。自动匹配根据物品分类、丢失地点、发布时间做相似度匹配。小程序端复用现有后端接口把页面迁移到微信小程序。缓存优化热门列表使用 Redis 缓存降低数据库压力。其中消息通知和管理员功能对业务价值提升最大也最容易在答辩中讲清楚。6.3 发布前检查清单校园失物招领系统在交付或答辩前建议逐项确认以下内容。检查项状态数据库脚本可重复执行手动勾选验证注册、登录、发布、列表、详情、认领、确认七条链路全部走通手动勾选验证非法输入不返回 500而是提示明确业务错误手动勾选验证密码未明文存储手动勾选验证接口跨域配置符合当前部署环境手动勾选验证前端发布页能区分寻物和招领类型手动勾选验证同一用户不能重复认领同一物品手动勾选验证物品完成后不能继续提交认领申请手动勾选验证控制台无 SQL 注入和异常堆栈刷屏手动勾选验证项目启动包名、端口和数据库配置和演示环境一致手动勾选验证这份清单最适合在开发收尾阶段使用。每确认一项再进入下一项能有效避免答辩现场出现“列表能进入但认领申请按钮点击后报 500”这类问题。校园失物招领系统的难点不在代码量而在业务状态的管理。把类型、状态、权限和事务处理好整个项目的数据链路会非常清晰。做完这个项目可以继续把用户登录换成 JWT把图片上传换成对象存储把静态页面替换成 Vue 或 React 应用——每一步扩展都在原基础上叠加新知识正好符合从课设走向工程实践的学习路径。