Spring Boot校园闲置物品租售管理系统全栈实战指南

📅 发布时间:2026/10/2 14:08:44
Spring Boot校园闲置物品租售管理系统全栈实战指南
毕业设计选这个题的人每年都有不少但真正能把“校园闲置物品租售管理系统”做出花来的没几个。Spring Boot作为当前后端开发最主流的框架配合Vue或者Thymeleaf做前后端再加MySQL存数据、Redis扛缓存、Minio存图片这套组合基本就是校园类管理系统的标准配置了。我自己前前后后带过几届毕设小组也独立做过类似的项目今天把这些经验完整梳理一遍从需求拆解、表结构设计、核心模块实现到最后的部署上线每一步的坑和细节都摆出来。这篇内容主要面向正在做Spring Boot毕设的同学也适合想独立完成一个完整项目来提升实战能力的后端初学者。1. 需求拆解与整体设计思路1.1 先从真实的校园场景理解业务很多同学拿到这个题目第一反应就是“做个商城”然后把淘宝那套搬过来。这其实是认知上的偏差。校园闲置物品租售和普通电商有本质区别它的人群固定都是本校学生、物品流通快学期末集中爆发、交易距离近同校甚至同楼当面交付而且它天然包含“租”和“售”两种完全不同的交易模式。先说说“租”。校园里哪些东西适合租单反相机、COS服、正装、教材、代步车这类物品使用频率低、单价偏高买新的不划算闲置又占地方。毕业后一届人走了下一届人接着用这个流转逻辑在校园里非常成熟。再说“售”毕业季的清仓甩卖、换季闲置的衣物、考研结束的复习资料这些东西就是纯买卖一手交钱一手交货。所以在设计系统之前你要先想清楚一件事这个系统的核心用户是本校学生核心价值是让闲置物品在同校熟人圈子里低成本流转。订单、支付、物流这些东西不是重点信息展示的清晰度、沟通的便捷性、交易的信任感才是重点。1.2 功能模块与数据库设计的合理取舍基于上面的业务理解我建议功能模块这样划分功能模块核心功能备注用户模块注册登录、个人信息、信用评价学生认证是校园系统的关键物品模块发布闲置、浏览搜索、分类筛选、收藏支持图片上传、多条件查询订单模块购买订单、租借订单、状态流转租借需记录租期与押金消息模块私信沟通、催还提醒、系统通知站内信即可不需要实时聊天管理后台用户管理、物品审核、订单监管、数据统计管理员独立端权限分离数据库设计方面建议核心表控制在10张以内表字段不要贪多。很多同学喜欢在一个表里塞几十个字段后面自己写CRUD都分不清。合理的做法是字段宁少勿多、能拆就拆。比如用户表就放基础信息和信用分地址、学校、学号这种如果不需要就砍掉物品表把图片单独拆一张表也好用逗号拼接字符串也罢只要你能自圆其说答辩时逻辑能讲通就行。这是我认为最稳妥的一套核心表结构-- 用户表 CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, nickname VARCHAR(50), avatar VARCHAR(255), phone VARCHAR(20), credit_score INT DEFAULT 100, -- 信用分 status TINYINT DEFAULT 1, -- 1正常 0禁用 create_time DATETIME ); -- 闲置物品表 CREATE TABLE idle_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, -- 发布人 title VARCHAR(100) NOT NULL, description TEXT, category_id BIGINT, price DECIMAL(10,2) NOT NULL, -- 售价 rent_price DECIMAL(10,2), -- 租金为空表示不可租 deposit DECIMAL(10,2), -- 押金 images VARCHAR(1000), -- 图片URL逗号分隔 status TINYINT DEFAULT 1, -- 1在售/可租 2已下架 3已售出/已出租 4审核中 view_count INT DEFAULT 0, create_time DATETIME ); -- 订单表 CREATE TABLE trade_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, item_id BIGINT NOT NULL, seller_id BIGINT NOT NULL, buyer_id BIGINT NOT NULL, order_type TINYINT, -- 1购买 2租借 amount DECIMAL(10,2), -- 实付金额 deposit DECIMAL(10,2), -- 租借押金 rent_days INT, -- 租期 status TINYINT, -- 状态机见下文 create_time DATETIME );这套表设计的核心思路是订单不冗余快照。这里我特意说明一下商城的订单一般会把商品名称、价格、图片冗余一份进来防止商家改价后订单数据不对。但在校园项目里物品状态相对稳定而且你查订单时大概率要关联查询物品信息冗余反而容易造成数据不一致。毕业设计答辩时老师问“为什么订单表不冗余商品快照”你回答“校园场景物品信息变动少冗余成本大于收益”这个答案是完全立得住的。1.3 租售一体的混合模式怎么设计才不翻车这是整个系统设计的灵魂也是最容易做崩的地方。很多人贪图省事把“租”和“售”做成两个模块各写各的。但实际业务中一个物品往往既能出售也能出租比如一台相机别人可以买走也可以租三天。如果你拆成两个物品来处理库存管理和数据一致性就是灾难。我的做法是物品表和下单逻辑统一通过订单类型字段区分租和售。idle_item.price是出售价rent_price是每天的租金deposit是押金用户下单时选择“购买”或“租借”生成不同type的订单物品状态根据订单类型进入不同的流转分支这里有个业务细节需要注意当一个物品同时具备“可买”和“可租”属性时要怎么处理我推荐一个比较简单的方案——用status字段限定1 在售可买不可租2 在租可租不可买3 在售且可租4 已下架这样查询列表时购买入口判断price ! null且status in (1,3)租借入口判断rent_price ! null且status in (2,3)逻辑清晰也不容易出状态错乱的问题。还有单物品同时被两个人租借的问题尤其是每一届做这个题目的同学都会碰到。我的处理是同一时间一个物品只能有一个人持有所以租借订单完成之前物品状态必须立即从“可租”切到“已出租”。这要求你在用户点击“立即租借”提交订单时用UPDATE ... WHERE status IN (2,3)做条件更新更新影响行数为0直接提示“手慢了物品已被租走”。这种乐观锁思路在校园项目里完全够用不用引入复杂的分布式锁。2. 技术选型与核心组件整合2.1 Spring Boot版本别盲目追新项目构建是第一步也是最容易卡住的地方。现在很多教程上来就让你用Spring Boot 3.x如果你是跟着网上的老教程做十有八九会栽在配置上。Spring Boot 3.x 的基线是 Java 17很多老版本依赖库没跟上比如 MyBatis 和 Druid 的某些版本就直接用不了。我做这个项目时选的是Spring Boot 2.7.x Java 8。选择理由非常现实大量教程、开源项目都基于这套组合遇到问题搜索引擎能直接给出答案2.7.x 是 2.x 的最后一个稳定主线功能完整且坑最少兼容性强无论你本机装的是 JDK 8 还是 JDK 11 都没问题如果你确实想用 3.x那我建议你同时把 JDK 升到 17然后把依赖版本全部换上支持 Jakarta 的新版本。尤其是javax.servlet到jakarta.servlet的包名变化很多老代码会报ClassNotFoundException不要被这种环境问题耽误了核心逻辑的时间。在 IDEA 里创建项目的路径是New Project → Spring Initializr → 选好 JDK 和 Spring Boot 版本 → 勾选 Web、MyBatis、MySQL Driver 等依赖。Maven 构建的话项目根目录下执行mvn clean install即可IDEA 右侧 Maven 面板里的package双击也是同样效果。番茄 说句实在话用 2.7 JDK 8 这个组合做完整个项目对绝大部分毕设场景已经完全够用了。你要是将来工作去了新项目组自然会接触到 3.x 甚至更新的版本那是另一套方法论。2.2 数据访问层选 MyBatis-Plus 而不是裸 MyBatis现在做这类项目我强烈推荐 MyBatis-Plus它不是替代 MyBatis而是在 MyBatis 之上做了一套增强工具。核心价值是单表CRUD不用写XML。用户表的增删改查、分页查询、条件构造器它内置的方法全都覆盖了。举个例子你要查“价格在100-200之间、状态在售、按浏览量倒序”的物品列表原生 MyBatis 你要写动态SQL但 MyBatis-Plus 的 LambdaQueryWrapper 几行搞定ListIdleItem items idleItemMapper.selectList( new LambdaQueryWrapperIdleItem() .between(IdleItem::getPrice, 100, 200) .in(IdleItem::getStatus, 1, 3) .orderByDesc(IdleItem::getViewCount) );分页插件也简单配置一个MybatisPlusInterceptor就行Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(50L); interceptor.addInnerInterceptor(pagination); return interceptor; } }这里有个细节maxLimit一定要设置。如果不设上限前端传一个pageSize999999一次查询就能把全表拉出来数据量大了之后服务直接卡死这也是一个安全漏洞。2.3 图片存储为什么必须把 Minio 整合进来校园闲置物品最核心的展示就是图片没有图片的物品基本无人问津。那图片存在哪最常见的错误做法是把图片丢到项目 static 目录或者用本地磁盘路径。这个方案的致命缺陷是重启或者重新部署时文件就丢了而且前后端分离部署时前端服务器根本访问不到后端机器的本地路径。正确解法是引入 Minio。Minio 是一个开源的对象存储服务兼容 S3 协议部署非常简单一个 Docker 命令就能跑起来。核心思路是图片等二进制文件统一上传到 Minio数据库里只存访问 URL应用服务器不保存任何文件。Minio 的部署方式docker run -d --name minio \ -p 9000:9000 -p 9001:9001 \ -e MINIO_ROOT_USERadmin \ -e MINIO_ROOT_PASSWORDadmin123456 \ -v /data/minio:/data \ minio/minio server /data --console-address :9001然后在 Spring Boot 里配置连接信息spring: servlet: multipart: max-file-size: 10MB max-request-size: 50MB minio: endpoint: http://localhost:9000 access-key: admin secret-key: admin123456 bucket-name: idle-items关键代码是上传逻辑有一个容易踩的坑获取文件后缀时不能直接用getOriginalFilename()去截取。有些浏览器上传的文件名是带路径的比如C:\Users\test\photo.jpg你直接截后缀很可能得到photo.jpg这种完整文件名拼接出来的对象名都是乱的。正确做法是用工具类从 MIME 类型或者原始文件名里剥离出真正的纯后缀。Service public class FileStorageService { Autowired private MinioClient minioClient; Value(${minio.bucket-name}) private String bucketName; public String upload(MultipartFile file) { // 先确保桶存在 try { boolean exists minioClient.bucketExists( BucketExistsArgs.builder().bucket(bucketName).build()); if (!exists) { minioClient.makeBucket( MakeBucketArgs.builder().bucket(bucketName).build()); } } catch (Exception e) { throw new RuntimeException(初始化存储桶失败); } String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.) 1); String objectName System.currentTimeMillis() _ UUID.randomUUID().toString().substring(0, 8) . ext; try { minioClient.putObject( PutObjectArgs.builder() .bucket(bucketName) .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build()); } catch (Exception e) { throw new RuntimeException(文件上传失败, e); } return / bucketName / objectName; } }注意返回值我存的是相对路径而不是完整的http://ip:9000开头的地址。这么做的原因很简单你本地开发时 IP 是localhost打包部署之后要换成服务器 IP如果你把完整地址存进数据库换环境就是一场灾难。存相对路径前端展示时拼上 Minio 的实际访问前缀就行一条配置文件解决所有问题。2.4 Redis JWT 做登录会话而不是 Session校园系统也是多用户系统用户的登录状态管理是绕不开的问题。用 Session 虽然简单但有跨域和集群部署的隐患。我建议用JWT Redis的方案JWT 存用户身份信息Redis 管 token 的过期和注销。登录流程大致是这样用户提交用户名密码后端校验通过后生成 JWT token把 token 存入 Rediskey 是login:token:{userId}value 是 token过期时间设为 2 小时前端每次请求把 token 放到请求头Authorization里后端写一个拦截器从请求头取 token解析出 userId查 Redis 对比是否一致用户退出时删除 Redis 中的 keytoken 立即失效Redis 在这里的核心作用是解决“JWT 本身无法主动失效”的问题。JWT 是无状态的签发之后在有效期内永远有效万一用户退出或者被盗你没法让 token 作废。加一层 Redis 之后服务端就能主动控制会话状态了。拦截器里的一段核心代码Component public class LoginInterceptor implements HandlerInterceptor { Autowired private StringRedisTemplate redisTemplate; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { response.setStatus(401); return false; } token token.substring(7); // 解析JWT得到userId Long userId JwtUtil.parseToken(token); if (userId null) { response.setStatus(401); return false; } // 校验Redis里的token是否一致 String redisKey login:token: userId; String cachedToken redisTemplate.opsForValue().get(redisKey); if (cachedToken null || !cachedToken.equals(token)) { response.setStatus(401); return false; } // 把userId放入request上下文后续Controller直接用 request.setAttribute(userId, userId); return true; } }这里的小技巧是让 Redis 里的 key 包含 userId这样踢人、续期都方便。如果你把 token 作为 key想知道“这个用户当前有哪些会话”就非常困难管理端想要强制下线某个用户也做不到。3. 核心业务模块的实现细节3.1 用户注册登录与校园身份验证用户模块看似简单但有两个容易被问倒的点。第一个是密码加密明文存库是毕设答辩中的大忌。用 BCrypt 加盐哈希Spring Security 自带BCryptPasswordEncoder或者你喜欢的话只用它的核心类不加整个 Spring Security 框架也行。加密逻辑很简单注册时加密存入登录时匹配校验。第二个点是校园身份验证。很多同学不知道怎么设计这块干脆不做这其实是个减分项。不需要对接真实的学号库最简单的做法是注册时填学校、学号、姓名管理员在后台审核通过后账号才能正常发布物品。这样既体现了系统的完整业务逻辑又避免了对接外部接口的复杂度答辩时完全说得通。登录接口还有一个细节失败次数限制。用 Redis 记一下login:fail:{username}的 PV5次失败后锁定10分钟。别小看这个功能它是面试官和高分答辩时非常喜欢的点体现了你的安全意识。// 登录失败时 String failKey login:fail: username; Long count redisTemplate.opsForValue().increment(failKey); if (count ! null count 1) { redisTemplate.expire(failKey, 10, TimeUnit.MINUTES); } if (count 5) { throw new BusinessException(失败次数过多请10分钟后再试); }3.2 物品发布与状态流转的完整逻辑物品发布是整个系统的重头戏。这部分有几个容易踩坑的地方图片上传要先于表单提交。很多新手把图片和表单一起提交一个接口接收 multipart 文件同时还要解析一堆文本字段前端代码和后端代码都很难维护。正确的做法是分成两个接口POST /api/item/image先传图片返回图片的相对路径POST /api/item提交物品信息图片字段放的是第一步返回的URL列表前端用一个chooseImage选择后立即上传预览区展示缩略图最后点“发布”时把图片URL数组跟着其他字段一起提交。物品状态不能只有“上架/下架”两个。我见过不少项目把物品状态做成布尔值然后发现订单和物品状态纠缠不清最后只能打补丁。建议用 int 类型设计几个清晰的语义状态状态值含义触发条件0待审核用户提交发布等待管理员审核1在售审核通过可下单购买2在租租借中不可被再次下单3可租可售两种交易都开放4已完成已售出或租借归还后进入完成态5已下架用户手动下架或管理员强制下架发布接口的校验逻辑也值得写一下标题长度限制5-30字、价格必须大于0、图片至少一张、描述不建议超过500字。这些限制不是憋出来的而是为了避免数据垃圾。你想想一个标题只有一个字的物品出现在列表里其他用户点进去发现啥信息都没有整个平台的体验都被拉低了。3.3 交易订单的状态机设计如果说物品模块是这个系统的手脚那订单模块就是心脏。订单状态设计得好不好直接决定你的系统是否专业。购买订单的状态流转建议这样待付款 - 待发货(可选) - 待收货(可选) - 已完成 | | | v v v 已取消 已取消 已取消校园场景里买卖双方基本都是校内当面交易物流环节其实可以弱化甚至去掉。我做过的最简方案是下单即锁定物品、支付或约定线下支付后进入“待交付”双方确认后进入“已完成”。有些同学在系统里把电商那套完整物流状态都做了最后发现根本没人用增加了大量无效工作。租借订单的状态流转更复杂一点待付款 - 租借中 - 待归还 - 已完成(已归还) | | | v v v 已取消 逾期中 已申请退还押金这中间有两个核心业务逻辑需要代码实现第一个是租金计算。租期按天算下单时计算总租金 每天租金 × 租期天数 押金。归还时如果逾期按超出的天数补收租金。这个计算的精度要注意BigDecimal是必须的不能用double否则金额会出现浮点数误差。第二个是到期提醒。租借订单到期前1天用定时任务扫描订单表查出所有status2且end_time在明天的订单给买家发一条站内信提醒归还。Spring Boot 里直接用Scheduled注解就行记得在启动类上加EnableScheduling。Component public class RentReminderJob { Autowired private OrderMapper orderMapper; Autowired private MessageService messageService; // 每天上午9点执行一次 Scheduled(cron 0 0 9 * * ?) public void remindExpiringRents() { LocalDate tomorrow LocalDate.now().plusDays(1); ListTradeOrder dueOrders orderMapper.selectList( new LambdaQueryWrapperTradeOrder() .eq(TradeOrder::getStatus, 2) .eq(TradeOrder::getOrderType, 2) .apply(DATE(end_time) {0}, tomorrow) ); for (TradeOrder order : dueOrders) { messageService.sendSystemMessage( order.getBuyerId(), 您租借的物品即将到期请尽快归还 ); } } }3.4 管理后台别只做花架子管理后台是答辩时的加分项但也最容易被做成“花架子”。很多同学后台就做几个列表页管理员啥也干不了。我的建议是后台重点做这四件事物品审核列表展示所有待审核物品管理员可以一键通过或驳回驳回时填写原因通知用户。这条流程让“校园管理”的元素落地了。用户管理列表展示所有用户能搜索、能禁用。禁用后 Redis 里这个用户的 token 直接删掉强制下线。订单监管查看所有订单异常订单比如投诉纠纷管理员有权限把订单置为“已关闭”。数据统计这是最容易出彩的一块。用几张图表展示每日新增物品数、订单成交量TOP10分类、用户活跃度等。后端只需要提供几个统计接口前端用 ECharts 画图。SQL 也不难比如// 统计最近7天每天的订单数 SELECT DATE(create_time) AS day, COUNT(*) AS cnt FROM trade_order WHERE create_time DATE_SUB(CURDATE(), INTERVAL 6 DAY) GROUP BY DATE(create_time)后台的前端页面不需要多精美布局清晰、操作流畅就够了。如果精力有限后台直接用 Thymeleaf 做服务端渲染减少一个前端的构建成本也是一种合理取舍。4. 从 IDEA 启动到 Docker 部署4.1 IDEA 中的启动配置细节开发阶段的启动配置是个小坑。很多同学从模板创建项目后不管端口直接启动结果发现 8080 端口被占用或者两个服务同时跑在 8080 上报错。在application.yml里明确指定端口是最稳妥的server: port: 8081 servlet: context-path: /api把context-path设为/api有一个好处所有后端接口统一以/api开头前端联调时代理配置只写一个前缀就行。而且将来如果要部署到 Nginx 反代路径转发规则也简单。IDEA 里的 Run Configuration 还要注意Active profiles要选对。如果你有application-dev.yml和application-prod.yml两个环境的配置本地启动时必须指定dev不然可能加载了生产环境的数据库连接。这个问题看似低级但我见过不止一个同学本地启动一直报数据库连接失败折腾半天发现是 profile 选错了。4.2 用 Docker 做一键部署项目做完要部署演示最省心的是 Docker。Dockerfile 写起来很简单核心就几个步骤构建 jar 包 → 拉一个 JDK 镜像 → 把 jar 放进去 → 指定启动命令。FROM openjdk:8-jdk-alpine LABEL maintaineryourname WORKDIR /app COPY target/idle-market-0.0.1-SNAPSHOT.jar app.jar EXPOSE 8081 ENTRYPOINT [java, -jar, app.jar, --spring.profiles.activeprod]这里有一个部署细节MySQL、Redis、Minio 都要用能互通的内网地址。比如 MySQL 不能用localhost连接因为 MySQL 跑在另一个容器里。最省事的方案是写一个docker-compose.yml把所有服务编排在一起version: 3 services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: idle_market ports: - 3306:3306 volumes: - mysql_data:/var/lib/mysql redis: image: redis:7-alpine ports: - 6379:6379 minio: image: minio/minio environment: MINIO_ROOT_USER: admin MINIO_ROOT_PASSWORD: admin123456 command: server /data --console-address :9001 ports: - 9000:9000 - 9001:9001 app: build: . depends_on: - mysql - redis - minio ports: - 8081:8081注意depends_on只保证容器启动顺序不保证 MySQL 已经初始化完成。这个没有完美的通用解最简单的方法是在 Spring Boot 的启动类里加一个启动等待逻辑或者用restart: always让应用启动失败后自动重启几次通常等 MySQL 起来了自然就连接成功了。4.3 前后端联调时的跨域与代理如果你用 Vue 做前端开发时的跨域问题是躲不过的。前后端分离架构下前端跑在 5173Vite 默认端口后端跑在 8081浏览器直接请求会报 CORS 错误。两个解决方案后端加全局跨域配置Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }前端开发环境用 Vite 代理// vite.config.js server: { proxy: { /api: { target: http://localhost:8081, changeOrigin: true } } }产线部署时更推荐 Nginx 反代统一入口前端静态资源由 Nginx 托管/api前缀的请求转发给后端服务这样浏览器层面就没有跨域问题了。5. 常见问题与排查技巧实录做这个项目过程中最耗时间的往往不是业务逻辑本身而是各种环境问题和隐蔽 Bug。我挑几个最高频的写下来这些坑几乎每个做 Spring Boot 毕设的人都会碰到。问题1Druid 连接池启动报错检查 MySQL 时区设置MySQL 8.x 默认时区和系统时区不一致连接时会出现Server returns invalid timezone的报错。在连接 URL 上加参数解决jdbc:mysql://localhost:3306/idle_market?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue问题2Maven 依赖下载缓慢或者拉取超时国内网络环境下Maven 中央仓库很不稳定。在settings.xml里配置阿里云镜像mirror idaliyun/id mirrorOfcentral/mirrorOf urlhttps://maven.aliyun.com/repository/public/url /mirror问题3前端请求接口返回 401但登录状态明明有效大概率是拦截器里没有放行登录、注册、图片访问这几个公开接口。在配置拦截器时明确排除掉白名单路径这是最容易被忽略的registry.addInterceptor(loginInterceptor) .addPathPatterns(/**) .excludePathPatterns(/api/user/login, /api/user/register) .excludePathPatterns(/api/item/list, /api/item/detail/**);问题4上传图片后页面不显示先检查 Minio 的访问地址是否能直接打开访问 URL 应该能看到图片内容。如果打不开大概率是存储桶的访问策略没有设置为公开读Minio Web 控制台里把access policy改成public即可。问题5JVM 内存不够打包或启动直接 OOMIDEA 构建 Maven 项目时内存不足在Maven Runner的 VM Options 里加-Xmx1024m -XX:MaxMetaspaceSize512m问题6租借订单归还后物品状态没有恢复这是状态机设计漏了一环。归还确认操作里除了更新订单状态必须同时更新物品状态。写个事务方法把“订单状态更新”和“物品状态回写”放在同一个事务里杜绝数据不一致Transactional public void confirmReturn(Long orderId) { TradeOrder order orderMapper.selectById(orderId); // 归还后物品状态回写为可租可售 IdleItem item idleItemMapper.selectById(order.getItemId()); item.setStatus(3); idleItemMapper.updateById(item); // 更新订单状态 order.setStatus(4); orderMapper.updateById(order); }我花了不少篇幅在那些不起眼的细节上因为整个项目从 0 到 1 顺利走下来真正的胜负手就是这些细节。MySQL 连接串多一个参数或少一个参数Docker 容器能不能互通拦截器白名单有没有覆盖所有公开接口这些看着都是小问题但任何一个爆出来都能卡你好几天。最后分享一个我个人非常推荐的做法整个项目完成之后把所有接口用 Postman 或者 Apifox 挨个过一遍把核心流程注册→登录→发布物品→下单→租借→归还→后台审核做成一个自动化测试集。这样一方面能在答辩演示时不慌不用担心现场出 Bug另一方面这套接口文档本身就是你毕业设计论文里“系统测试”章节的最佳素材。校园闲置物品租售系统这个题目做了这么多年本质考验的就是你能否老老实实把每一个环节打通不投机取巧。把上面这些内容消化透这个项目你不仅能做出来还能在答辩时讲出设计逻辑这比代码本身更能让你拿高分。