每年毕业季宿舍楼下总会出现成堆的教材、台灯和收纳箱扔了心疼、带走又装不下。我当初做这个基于 Spring Boot 的校园二手物品置换系统最直接的想法就是把闲置互换的冲动落地成一套能用起来的管理流程。它不是一个大而全的电商平台核心只解决三个问题让用户快速发布闲置、通过搜索分类找到想要的东西、以及用以物换物的方式完成置换而不是纠结于十几块钱的现金结算。这篇文章会从需求边界、数据库设计、接口实现写到部署踩坑适合三类人看正在选毕设题目的同学可以参考模块划分刚学完 Spring Boot 基础、想完整走一遍项目的初学者可以照着复现已经写了类似系统但感觉业务逻辑乱的人重点看看置换状态机和我后面讲的并发坑。1. 为什么校园二手交易需要一个置换系统1.1 宿舍楼下的真实需求场景先说需求。校园场景和外面的二手市场有一个典型差异物品单价太低。一本旧教材定价 10 块来回砍价半小时最后可能还因为还得约个地方见面而放弃交易。但如果换成置换双方都觉得不吃亏——我用一本概率论换你的英语四级真题谁也没掏钱心理预期完全不同。另外校园用户高度集中宿舍楼、教学楼、食堂之间步行距离都在五到十分钟。这个地理条件让同校面对面置换天然比跨城物流二手交易更有可行性。系统里不需要做复杂的物流追踪、运费计算、在线支付只要把信息撮合和交易状态管理做好就能解决大部分问题。提示这条边界很重要。很多项目失败在把需求无限放大又是支付、又是物流、又是客服最后每个模块都是半成品。校园置换系统的核心价值就是撮合 状态管理。1.2 技术栈选择与技术风险控制技术栈上我选了 Spring Boot 作为后端基础。原因很实际一是它内嵌 Tomcat、自动配置丰富能用最少的配置把项目跑起来二是成熟的 starter 生态MyBatis-Plus、JWT、Redis 这些都能快速集成适合单人开发三是社区资料多遇到问题搜一下就有解决方案对新手非常友好。我在选型时做了一个克制不用 Spring Cloud 那套微服务也不用分布式事务。校园项目的并发量根本不需要把这些复杂组件引进来只会拉高部署成本和排错难度。缓存先用本地 Map 或 Redis 单机分页用 MyBatis-Plus 自带的分页插件鉴权用 JWT 加自定义拦截器够用且容易讲清楚。这个选择的本质是技术服务于项目阶段。做毕设或课程设计最重要的是把业务逻辑跑通、把设计思路表达清楚而不是堆砌技术名词。2. 功能模块与角色权限的边界划分2.1 两种角色 游客的权限矩阵系统我设计成三种身份游客、普通用户、管理员。游客可以浏览物品列表、看详情和搜索但一旦要发布物品或发起置换申请就必须登录。这样做既降低了游客门槛又保证操作可追溯。用户登录后可以发布、编辑、上下架自己的物品对别人的物品发起置换申请查看收到的申请并确认或拒绝还可以收藏、留言和使用信用分。管理员主要是做平台治理审核物品、禁用违规用户、管理分类、处理举报和看统计数据。功能游客普通用户管理员浏览/搜索/查看详情支持支持支持发布/编辑/上下架物品不支持支持仅本人可下架他人违规物品发起/确认/拒绝置换不支持支持只读分类管理只读只读支持用户禁用/解禁不支持不支持支持实现上我没有引入 Spring Security因为项目的权限模型很简单。用拦截器校验 JWT再在需要管理员权限的接口上标注一个角色注解AOP 里判断角色即可。少一层安全框架代码更直观。2.2 五大核心模块的职责拆分把需求拆成模块我保留了五个用户模块注册、登录、个人信息维护、信用分查看。物品模块分类、发布、编辑、图片上传、上下架、搜索。置换模块发起申请、收到申请、确认置换、拒绝、取消、完成订单。消息模块站内消息通知比如某人申请了你的物品、对方确认了你发起的申请。后台管理模块用户管理、物品审核、分类管理、举报处理。模块之间是单向依赖关系消息模块依赖置换模块产生的事件置换模块依赖物品模块的状态物品模块依赖用户模块的鉴权。这样组织代码时 service 层不会出现循环依赖包结构也清晰。2.3 置换业务中的状态机设计置换是系统里最核心的业务状态机必须提前定义清楚否则写代码的时候一定会乱。我给物品定义了一个 status 字段整数类型0上架中可被申请1置换中已经被某个申请占用2已置换流程结束3已下架可能是发布者主动下架或管理员下架置换申请记录同样有状态0 待处理、1 已通过、2 已拒绝、3 已取消。状态变化有固定规则比如物品状态只有从 0 才能变成 1只有从 1 才能变成 2不从 2 回退到 0。代码里我把这些状态流转收敛到 service 层的方法中不允许 controller 直接修改状态字段。这样后续加需求或排查数据问题时只需要盯着几个入口方法。3. 数据库表结构设计的几个关键决策3.1 核心表清单与关系说明数据库我用了 MySQL 8.0字符集 utf8mb4。整体上设计成 8 张表职责非常明确表名职责user用户账号、昵称、手机号、信用分category物品分类比如教材、电子产品、生活用品item闲置物品主信息item_image物品图片一对多exchange_request置换申请记录exchange_order置换订单申请通过后生成message站内消息favorite收藏关系表后来维护时我发现图片单独一张表比在 item 表里放一个逗号分隔的 URL 字段要灵活得多。上传图片的顺序、后续新增图片、删除单张图都能用 SQL 精确控制。3.2 物品表和订单表的 DDL 经验物品表的核心字段 DDL 大致如下实际字段更多但关键是这些CREATE TABLE item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, category_id BIGINT NOT NULL, title VARCHAR(80) NOT NULL, description TEXT, expect_item VARCHAR(100) COMMENT 期望换到的物品, status TINYINT NOT NULL DEFAULT 0 COMMENT 0上架 1置换中 2已置换 3下架, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, deleted TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, KEY idx_user (user_id), KEY idx_category_status (category_id, status) );这里有两个容易被忽视的设计点。一是 version 字段后面讲并发问题时会重点说明二是 create_time 和 update_time 的维护方式。我最终选择在应用层用 MyBatis-Plus 的自动填充注解统一处理避免每张表都重复写插入时间戳。交换订单表则偏向结果记录CREATE TABLE exchange_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, request_id BIGINT NOT NULL, from_user_id BIGINT NOT NULL, to_user_id BIGINT NOT NULL, from_item_id BIGINT NOT NULL, to_item_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0, complete_time DATETIME, create_time DATETIME NOT NULL );订单的核心意义是留下一次置换的完整快照。from_user 是申请方to_user 是物品发布方双方的物品 ID 都记录下来。后续就算物品下架或修改订单里的记录仍然能还原当时的交易对象。3.3 为什么要把申请和订单拆成两张表这是我在设计过程中反复纠结过的一个点也是很多同学的疑问申请通过后直接更新申请记录的状态不就行了为什么还要单独建一张 exchange_order原因是语义不同。申请是我想要你的东西的意向一个人可以同时向多个物品发起申请一个物品也会收到多个申请申请可以随时取消没有契约约束。订单则是申请被确认后产生的契约只能二选一确认一个申请订单进入流程后就要按状态流转。如果混在同一张表你会发现同一个物品收到的多条申请里很难干净地表达哪条申请最终进入了置换流程。拆分之后exchange_request 只管意向的增删和审核exchange_order 只管成交后的状态逻辑各自独立。消息通知、信用分变更、数据统计都可以直接关联订单表不会读到一堆无关的申请记录。4. 从登录到置换完成的接口实现4.1 登录鉴权JWT 拦截器的轻量方案鉴权方案我用了 JWT实现简单、无状态适合前后端分离的小项目。用户登录成功后后端生成一个包含 userId 和角色信息的 token 返回给前端前端每次请求在 Header 里带上后端拦截器解析校验。核心拦截器的关键判断逻辑大致是这样Component public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equals(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (!StringUtils.hasText(token)) { response.setStatus(401); return false; } try { Long userId JwtUtil.parseToken(token); request.setAttribute(userId, userId); return true; } catch (Exception e) { response.setStatus(401); return false; } } }这里要注意一个细节白名单路径必须放行。登录接口、注册接口、物品列表接口都要在配置里跳过拦截否则会出现用户刚开始操作就被拦截的尴尬。这个坑我踩过后面第 5 节会细说。4.2 发布物品和图片上传路径的约定图片上传是校园项目里最容易出问题的小功能。我的做法是不把图片存进数据库字段而是上传到服务器本地指定目录数据库只存相对路径。相对路径的好处是以后换服务器或迁到第三方存储时不用改数据库。Controller 层代码大致是这样PostMapping(/api/item) public Result addItem(RequestParam(title) String title, RequestParam(value files, required false) MultipartFile[] files) { ListString imageUrls new ArrayList(); if (files ! null) { for (MultipartFile file : files) { String fileName UUID.randomUUID() . FilenameUtils.getExtension(file.getOriginalFilename()); file.transferTo(new File(uploadDir fileName)); imageUrls.add(/images/ fileName); } } // 组装并保存物品信息 }用 UUID 做文件名是为了防止重名和避免中文文件名在浏览器里编码混乱。uploadDir 这个路径要写在配置文件中不要写死否则换一台服务器就找不到图了。4.3 发起置换申请与确认置换的防重逻辑发起申请的接口看起来简单但要注意一个核心约束同一件物品一旦进入置换中状态就不应该再被其他人成功申请。最直接的做法是在插入申请前先查询物品状态但并发场景下查询再插入是存在竞态的。我最终用了一条带条件的更新语句来完成状态抢占Java 代码示意如下Transactional(rollbackFor Exception.class) public boolean confirmRequest(Long requestId, Long itemId, Long ownerId) { // 先尝试把物品从上架中(0)改为置换中(1) int updated itemMapper.updateStatusWithCondition(itemId, ownerId, 0, 1); if (updated 0) { return false; // 说明物品已经被抢占或者已被下架 } // 更新申请记录为通过 exchangeRequestMapper.updateStatusById(requestId, RequestStatus.PASSED); // 拒绝其他所有待处理申请 exchangeRequestMapper.rejectOtherPending(itemId, requestId); // 生成订单 exchangeOrderMapper.insert(buildOrder(requestId)); return true; }我的经验是先改状态、后写订单的顺序必须保持稳定。如果先插订单再改物品状态一旦后续事务回滚订单可能已经插进去了逻辑会很拧巴。状态抢占的 update 语句要加上 ownerId 条件保证只有物品持有者本人能请求这个操作这是权限校验在 SQL 层面的兜底。4.4 列表、搜索与分页的实现细节物品列表是访问量最大的接口。我用 MyBatis-Plus 的分页插件封装了一个通用的查询方法支持分类筛选、关键词模糊搜索、按发布时间或点击量排序。模糊搜索和分页语句大致是PageItem page new Page(pageNum, pageSize); LambdaQueryWrapperItem wrapper new LambdaQueryWrapper(); wrapper.eq(Item::getStatus, 0) .eq(categoryId ! null, Item::getCategoryId, categoryId) .and(StringUtils.hasText(keyword), w - w.like(Item::getTitle, keyword).or().like(Item::getDescription, keyword)) .orderByDesc(Item::getUpdateTime); page itemMapper.selectPage(page, wrapper);这里有一个对新手非常重要的认知like(%keyword%)是扫全表的在校园规模下数据量可能只有几千条压力不大但如果后续想扩大范围就必须考虑更高效的搜索方案。课程设计阶段不用过度优化先把索引加对、把 SQL 写清楚就足够应付演示和答辩了。5. 开发过程中踩过的五个典型坑5.1 并发确认导致同一物品被置换两次这个坑是我在本地模拟多个用户同时点击确认置换时复现的。最初代码逻辑是先查状态状态为 0 就改成 1然后生成订单。两台测试客户端同时点两个线程都读到状态 0都进入后续流程结果一件物品生成了两个订单。解决方式就是前面说的乐观锁和条件更新。我写了专门的状态更新语句确保把 status 从 0 改成 1这一步在同一时刻只会有一个线程成功更新行数为 1 才继续否则立即返回失败提示该物品已被申请置换请刷新看看其他宝贝。从那以后没有再加出过双订单问题。5.2 图片上传后访问 404 的磁盘路径问题本地开发时图片一切正常部署到 Linux 服务器后上传成功但访问图片全是 404。排查到最后发现两个原因一是本地用的绝对路径是 Windows 风格Linux 不认二是我把图片路径写进了项目相对路径重启后临时目录被清理图片就没了。正确做法是把上传目录配置在 application.yml 里通过配置文件注入file: upload-dir: /data/campus-exchange/images/ access-prefix: /images/**同时在后端注册一个静态资源映射把 /images/** 指向实际磁盘目录。部署时还要保证该目录有写权限这些在本地开发时完全不会暴露只有上服务器才踩得到。5.3 登录态过期后前端无限跳转登录页前端项目里我给所有请求包了一个统一的响应拦截函数只要后端返回 401 就跳转登录页。问题是一开始物品列表、分类接口也在拦截器保护范围内游客访问时后端返回 401前端就不断跳登录页根本进不了首页。排查思路很简单打开浏览器控制台看网络请求发现连分类列表接口都被返回 401。修复方式是在后端拦截器配置里把公开接口加进白名单前端也做一层区分只有业务接口的 401 才跳转登录页。提示接口白名单建议在项目里单独建一个配置类维护把所有无需登录的路径集中管理。散落在各个 Controller 里判断的话后期非常难维护。5.4 like 模糊搜索导致慢查询物品多了以后关键词搜索开始变慢。一开始我在 title、description 两个字段上同时做模糊匹配每当用户输入常见词数据库就要扫全表。我先是给 title 加了普通索引但like %xx%依然走不了索引效果有限。对这个项目我的处理方式有两点一是搜索范围收敛到标题和分类名称不再全文匹配描述二是给物品表增加一个点击量字段热门搜索词直接走缓存的热门列表。课程设计这种规模下做到这一步已经足够。真要上全文检索那是另一个层面的课题没必要在这个系统里硬塞。5.5 大图片上传拖垮接口响应手机拍照一张就 3M 到 8M上传时不仅慢还有极小概率把后端请求线程占住很久导致整个应用响应变慢。我的修复分三层前端先压缩图片再上传限制单张不超过 1M后端限制单文件 5M 以内上传完成后记录日志文件过大直接拒绝并提示用户。另外一个容易漏的配置是 Nginx 的client_max_body_size默认只有 1M前端以为传成功了其实已经被反向代理直接拦截。部署检查清单里必须加上这一项否则线上必踩。6. 部署试运行后的优化笔记6.1 模拟运行数据下的性能表现整个项目写完后我用模拟数据做了一轮压测构造 2000 个物品、300 个用户、5000 条置换申请部署在一台 2 核 4G 的虚拟机上。单机不引入缓存时物品列表接口平均响应在 80ms 左右关键词搜索在 150ms 左右没有明显瓶颈。加了一个简单的缓存之后首页接口降到了 30ms 以内。这个结果说明Spring Boot 加 MySQL 的经典组合应对小型校园项目完全没问题。真正影响体验的不是框架本身而是索引、慢查询和图片带宽。所以如果有人说这个技术栈性能不行先去看看是不是 SQL 写成了全表扫描。6.2 部署清单与上线前检查表部署时我把关键配置整理成了一份检查清单每次上线前逐项核对数据库字符集使用 utf8mb4避免 emoji 昵称报错。application.yml 中的文件上传路径改为服务器绝对路径。Nginx 里client_max_body_size至少设置 10M。JWT 的密钥不要用默认值必须改成随机字符串。检查静态资源映射无误确认反向代理覆盖了 /images/ 路径。生产环境数据库账号不要用 root用最小权限账号。启动脚本里设置-Xms512m -Xmx1024m避免内存抖动。这些检查项没有一个是高深技术但每一条都对应真实的线上故障。每次我觉得应该没问题、直接上吧的时候都会回头再过一遍清单。6.3 给后来者的三条实操建议最后说几句个人体会。第一项目宁可把核心流程做到稳定也不要贪模块数量。这个系统最核心的就是置换状态机我把精力都花在这一条链路上最终的效果反而比什么都做一点好得多。第二写代码前先用一张纸画清楚状态流转图。申请、确认、拒绝、取消、完成之间的跳转关系越早画清楚越省事。我见过太多人写 service 时反复改状态字段导致数据逻辑乱成一团。第三本地能跑通不代表部署能跑通。提前一天在干净的 Linux 环境里从零按部署文档走一遍能帮你发现一大堆我本地明明可以的问题。图片路径、端口占用、数据库权限这些坑提前排掉演示或答辩的时候才不会当场翻车。写到这儿这个校园二手物品置换系统的开发过程就讲得差不多了。每次看到数据表里那一条条成功置换的记录我都会想起最开始在宿舍楼下看到的那堆旧书——一套能用的撮合流程确实比单纯写一堆 CRUD 有意思得多。