ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Spring Boot的校园失物招领系统全栈开发实战

基于Spring Boot的校园失物招领系统全栈开发实战 还在为食堂丢卡、教室丢书、操场丢钥匙发愁校园里的失物招领信息往往散落在表白墙、班级群、公告栏里找一件东西要翻几十条聊天记录捡到东西也不知道交给谁。本文将围绕校园失物招领系统从需求分析、数据库设计、后端接口到前端页面完整走一遍开发流程帮助你把寻物和招领信息统一管理起来。文章适合正在做课程设计、毕业设计或者想练习 Spring Boot 全栈开发的同学阅读。代码基于 Spring Boot 2.7 MyBatis-Plus Thymeleaf MySQL 实现思路清晰、可以直接运行也方便二次扩展。1. 项目背景与核心概念1.1 校园失物招领的现状与痛点大学校园是一个流动性很强的环境教室、图书馆、食堂、操场都是失物高发区。大多数学校目前仍然依靠线下失物招领处、QQ 群消息、朋友圈转发来扩散信息。这种方式有几个明显的问题信息分散失物信息散落在各个群聊和平台学生不知道该去哪里查。时效性差一条招领信息发出后很快被新消息淹没。缺少闭环物品被认领后发布者不会回来更新状态其他人还在反复询问。信息可信度低仅凭一张图片或一句话很难判断物品是否还在、该联系谁。校园失物招领系统要解决的就是把这些散落的信息集中到一个平台上让“丢失登记”和“捡到登记”有统一的入口并且能跟踪从发布到认领的完整状态。1.2 系统要具备哪些能力一个最小可用的校园失物招领系统需要覆盖以下几个核心场景学生发布“寻物启事”我丢了东西。学生发布“失物招领”我捡到了东西。浏览者可以按关键词、类型筛选信息。拾主或失主可以在详情页提交认领/留言。信息发布者可以确认认领并标记“已完成”。围绕这些场景系统至少要包含用户、失物信息、认领记录三块数据模型。如果只做一个纯展示页面其实意义不大关键是“发布—发现—认领—确认”这条业务闭环要跑通。1.3 本文的技术方案选型考虑到教学和课程设计的需要本文采用一套“单体应用”方案技术说明Java 8后端开发语言Spring Boot 2.7.x应用框架简化配置和部署MyBatis-Plus 3.5.x数据访问层框架内置 CRUD 方法MySQL 5.7 / 8.0关系型数据库Thymeleaf服务端模板引擎页面直接渲染数据Bootstrap 5前端样式框架通过 CDN 引入这套组合的好处是结构简单、开发效率高不需要额外搭建前端工程适合单体应用快速落地。如果你后续想改造成前后端分离只要把 Controller 层改成返回 JSON 的 REST 接口即可。2. 系统分析与功能设计2.1 角色与功能拆分系统只涉及两类角色普通用户注册登录后可以发布信息、提交认领申请。信息发布者对自己发布的信息有管理权限可以查看认领申请、确认完成。按功能模块划分如下模块功能点用户模块注册、登录、退出登录信息模块发布寻物/招领、列表展示、关键词搜索、类型筛选详情模块查看详情、提交认领申请管理模块发布者确认认领、标记信息完成这里刻意没有做管理员后台。对课程设计来说先把用户维度的闭环跑通更关键管理员审核、数据统计可以作为后续扩展。2.2 业务闭环流程整个系统的核心流程可以归纳为用户注册登录。用户发布一条“寻物启事”或“失物招领”。其他用户通过首页搜索、筛选找到这条信息。用户进入详情页提交认领申请并附上线索说明。发布者查看认领申请确认某条认领后信息状态变为“已完成”。这个流程看起来简单但值得注意的点在于认领不是直接暴露联系方式而是通过申请-确认机制避免别有用心的人冒领物品。这也是校园失物招领系统区别于普通信息发布网站的核心。2.3 项目结构规划后端统一使用com.example.lostfound作为包名src/main/java/com/example/lostfound/ ├── LostFoundApplication.java # 启动类 ├── config/ │ └── WebConfig.java # 登录拦截器配置 ├── controller/ │ ├── HomeController.java # 首页与搜索 │ ├── PublishController.java # 发布、详情、认领 │ └── UserController.java # 登录注册 ├── entity/ │ ├── User.java │ ├── LostItem.java │ └── ClaimRecord.java ├── mapper/ │ ├── UserMapper.java │ ├── LostItemMapper.java │ └── ClaimRecordMapper.java └── service/ ├── UserService.java ├── UserServiceImpl.java ├── LostItemService.java └── LostItemServiceImpl.java资源文件目录src/main/resources/ ├── application.yml # 应用配置文件 └── templates/ ├── index.html # 首页列表 ├── login.html # 登录页 ├── register.html # 注册页 ├── publish.html # 发布页 └── detail.html # 详情页3. 环境准备与数据库设计3.1 开发环境说明本文示例以 Java 8 Spring Boot 2.7.x MySQL 8.0 为常见环境实际版本可以根据你的项目情况调整。需要提前安装JDK 1.8 或以上Maven 3.6 或以上MySQL 5.7 或 8.0IDEA 或 Eclipse如果本机没有安装 MySQL也可以用 Docker 快速启动一个示例数据库避免污染本机环境。3.2 数据表结构设计系统一共设计三张表user用户表存储账号、密码、昵称、手机号。lost_item失物信息表通过type字段区分“寻物”和“招领”。claim_record认领记录表记录谁在哪条信息下提交了认领申请。lost_item中的status字段表示处理状态0 为待处理1 为已完成。这里没有用两张表分别存“寻物”和“招领”而是用类型字段区分查询和扩展都更方便。建表 SQL 如下可以直接在 MySQL 中执行CREATE DATABASE IF NOT EXISTS lost_found DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE lost_found; CREATE TABLE user ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password VARCHAR(100) NOT NULL, nickname VARCHAR(50) DEFAULT NULL, phone VARCHAR(20) DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE lost_item ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, type VARCHAR(10) NOT NULL COMMENT lost-寻物 found-招领, title VARCHAR(100) NOT NULL, description TEXT, category VARCHAR(30) DEFAULT NULL, place VARCHAR(100) DEFAULT NULL, status TINYINT DEFAULT 0 COMMENT 0-待处理 1-已完成, contact_info VARCHAR(100) DEFAULT NULL, image_url VARCHAR(255) DEFAULT NULL, deleted TINYINT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_type (type), KEY idx_status (status), KEY idx_create_time (create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE claim_record ( id BIGINT NOT NULL AUTO_INCREMENT, item_id BIGINT NOT NULL, user_id BIGINT NOT NULL, nickname VARCHAR(50) DEFAULT NULL, content VARCHAR(500) DEFAULT NULL, contact_info VARCHAR(100) DEFAULT NULL, status TINYINT DEFAULT 0 COMMENT 0-待确认 1-已同意, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_item_id (item_id), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;说明lost_item表中的deleted字段用于 MyBatis-Plus 逻辑删除。这样信息被删除后不会真的从数据库消失便于以后追溯也符合校内应用“谨慎删除”的诉求。3.3 创建 Spring Boot 工程并添加依赖创建工程时选择 Maven 项目然后在pom.xml中添加核心依赖parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.security/groupId artifactIdspring-security-crypto/artifactId /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies这里额外引入了spring-security-crypto只用于密码的 BCrypt 加密不会引入完整的 Spring Security 权限框架避免增加学习成本。接着在application.yml中配置数据源和 MyBatis-Plusserver: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/lost_found?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 thymeleaf: cache: false mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0需要注意password要改成你自己本机 MySQL 的密码。serverTimezoneAsia/Shanghai是为了解决 MySQL 8.x 的时区报错问题。4. 后端核心代码实现4.1 实体类与 Mapper 层使用 MyBatis-Plus 时实体类通过注解映射数据库表。先看用户实体// 文件路径src/main/java/com/example/lostfound/entity/User.java package com.example.lostfound.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.time.LocalDateTime; Data TableName(user) public class User { TableId(type IdType.AUTO) private Long id; private String username; private String password; private String nickname; private String phone; private LocalDateTime createTime; }失物信息实体// 文件路径src/main/java/com/example/lostfound/entity/LostItem.java package com.example.lostfound.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.time.LocalDateTime; Data TableName(lost_item) public class LostItem { TableId(type IdType.AUTO) private Long id; private Long userId; /** lost-寻物 found-招领 */ private String type; private String title; private String description; private String category; private String place; /** 0-待处理 1-已完成 */ private Integer status; private String contactInfo; private String imageUrl; private Integer deleted; private LocalDateTime createTime; private LocalDateTime updateTime; }认领记录实体// 文件路径src/main/java/com/example/lostfound/entity/ClaimRecord.java package com.example.lostfound.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.time.LocalDateTime; Data TableName(claim_record) public class ClaimRecord { TableId(type IdType.AUTO) private Long id; private Long itemId; private Long userId; private String nickname; private String content; private String contactInfo; /** 0-待确认 1-已同意 */ private Integer status; private LocalDateTime createTime; }Mapper 层代码非常简洁直接继承BaseMapper就拥有了单表 CRUD 能力// 文件路径src/main/java/com/example/lostfound/mapper/UserMapper.java package com.example.lostfound.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.lostfound.entity.User; public interface UserMapper extends BaseMapperUser { }LostItemMapper和ClaimRecordMapper结构完全一样这里不再重复贴出。4.2 Service 层实现Service 接口继承 MyBatis-Plus 的IService实现类继承ServiceImpl。先看用户模块// 文件路径src/main/java/com/example/lostfound/service/UserService.java package com.example.lostfound.service; import com.baomidou.mybatisplus.extension.service.IService; import com.example.lostfound.entity.User; public interface UserService extends IServiceUser { User login(String username, String password); /** * 注册成功返回 null失败返回错误提示 */ String register(User user); }实现类// 文件路径src/main/java/com/example/lostfound/service/UserServiceImpl.java package com.example.lostfound.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.example.lostfound.entity.User; import com.example.lostfound.mapper.UserMapper; import com.example.lostfound.service.UserService; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import javax.annotation.Resource; Service public class UserServiceImpl extends ServiceImplUserMapper, User implements UserService { Resource private PasswordEncoder passwordEncoder; Override public User login(String username, String password) { User user getOne(new LambdaQueryWrapperUser() .eq(User::getUsername, username)); if (user ! null passwordEncoder.matches(password, user.getPassword())) { return user; } return null; } Override public String register(User user) { long count count(new LambdaQueryWrapperUser() .eq(User::getUsername, user.getUsername())); if (count 0) { return 用户名已存在; } user.setPassword(passwordEncoder.encode(user.getPassword())); save(user); return null; } }这里做了两件关键的事注册时用passwordEncoder.encode()对密码加密数据库里不存明文。登录时用matches()校验明文密码和密文是否匹配。有些教程直接使用 MD5 加密虽然也能跑通但 BCrypt 自带随机盐安全性更高也符合工程实践要求。失物信息模块// 文件路径src/main/java/com/example/lostfound/service/LostItemService.java package com.example.lostfound.service; import com.baomidou.mybatisplus.extension.service.IService; import com.example.lostfound.entity.LostItem; import java.util.List; public interface LostItemService extends IServiceLostItem { ListLostItem search(String keyword, String type); }实现类// 文件路径src/main/java/com/example/lostfound/service/LostItemServiceImpl.java package com.example.lostfound.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.example.lostfound.entity.LostItem; import com.example.lostfound.mapper.LostItemMapper; import com.example.lostfound.service.LostItemService; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import java.util.List; Service public class LostItemServiceImpl extends ServiceImplLostItemMapper, LostItem implements LostItemService { Override public ListLostItem search(String keyword, String type) { return list(new LambdaQueryWrapperLostItem() .eq(StringUtils.hasText(type), LostItem::getType, type) .like(StringUtils.hasText(keyword), LostItem::getTitle, keyword) .orderByDesc(LostItem::getCreateTime)); } }eq(condition, column, value)这种写法会在条件不成立时自动忽略该查询条件。所以当type为空时不会拼接type 避免查询结果异常。4.3 Controller 层实现首页 Controller 负责列表展示和搜索// 文件路径src/main/java/com/example/lostfound/controller/HomeController.java package com.example.lostfound.controller; import com.example.lostfound.service.LostItemService; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import javax.annotation.Resource; Controller public class HomeController { Resource private LostItemService lostItemService; GetMapping(/) public String index(RequestParam(required false) String keyword, RequestParam(required false) String type, Model model) { model.addAttribute(items, lostItemService.search(keyword, type)); model.addAttribute(keyword, keyword); model.addAttribute(type, type); return index; } }用户 Controller// 文件路径src/main/java/com/example/lostfound/controller/UserController.java package com.example.lostfound.controller; import com.example.lostfound.entity.User; import com.example.lostfound.service.UserService; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import javax.annotation.Resource; import javax.servlet.http.HttpSession; Controller public class UserController { Resource private UserService userService; GetMapping(/login) public String loginPage() { return login; } PostMapping(/login) public String doLogin(String username, String password, HttpSession session, Model model) { User user userService.login(username, password); if (user null) { model.addAttribute(error, 用户名或密码错误); return login; } session.setAttribute(loginUser, user); return redirect:/; } GetMapping(/register) public String registerPage() { return register; } PostMapping(/register) public String doRegister(User user, Model model) { String msg userService.register(user); if (msg ! null) { model.addAttribute(error, msg); return register; } return redirect:/login; } GetMapping(/logout) public String logout(HttpSession session) { session.invalidate(); return redirect:/; } }发布和认领相关的 Controller 是业务重点// 文件路径src/main/java/com/example/lostfound/controller/PublishController.java package com.example.lostfound.controller; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.example.lostfound.entity.ClaimRecord; import com.example.lostfound.entity.LostItem; import com.example.lostfound.entity.User; import com.example.lostfound.service.ClaimRecordService; import com.example.lostfound.service.LostItemService; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; import javax.servlet.http.HttpSession; import java.util.List; Controller public class PublishController { Resource private LostItemService lostItemService; Resource private ClaimRecordService claimRecordService; GetMapping(/publish) public String publishPage() { return publish; } PostMapping(/publish) public String doPublish(LostItem item, HttpSession session) { User user (User) session.getAttribute(loginUser); item.setUserId(user.getId()); item.setStatus(0); lostItemService.save(item); return redirect:/; } GetMapping(/item/{id}) public String detail(PathVariable Long id, Model model, HttpSession session) { LostItem item lostItemService.getById(id); if (item null) { return redirect:/; } ListClaimRecord claims claimRecordService.list( new LambdaQueryWrapperClaimRecord() .eq(ClaimRecord::getItemId, id) .orderByDesc(ClaimRecord::getCreateTime)); User loginUser (User) session.getAttribute(loginUser); model.addAttribute(item, item); model.addAttribute(claims, claims); model.addAttribute(isOwner, loginUser ! null item.getUserId().equals(loginUser.getId())); return detail; } PostMapping(/item/{id}/claim)
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进