每年到了课程设计或者毕业设计的季节总有人跑来问我做什么题目最稳我的答案里永远有一个位置留给“二手交易平台”。为什么因为这类系统业务场景特别直观谁都买过书、卖过闲置理解成本几乎为零同时它又能把注册登录、商品管理、订单流转、文件上传、权限控制这些后端常见功能全部串起来一个项目做完Spring Boot的那套东西基本就摸透了。我这次拿出来的项目是基于Spring Boot的二手书交易系统现在网上不少版本也直接叫“二手漫画订购系统”。名字怎么叫其实无所谓核心都是同一件事让用户把闲置的书籍、漫画挂到平台上卖其他用户浏览、下单、模拟支付管理员在后台审核商品、管理用户和订单。整个工程包含了源码、数据库文件和配套文档我按自己当时做课设的结构完整拆一遍顺便把最容易踩坑的几个地方也一并讲清楚。这篇内容比较适合两类人一是正在选课设/毕设题目、想找一个“经典但不烂大街”方向的同学二是已经把Spring Boot学了个大概、想通过一个完整项目把知识点串起来的人。我对二手交易系统的定位是“中层复杂度”比增删改查的学生管理系统高一层又远没到秒杀系统那种并发地狱拿来练手、答辩、写论文都很合适。1. 项目整体规划先想清楚做到什么程度再动手1.1 功能边界怎么定很多同学拿到这种题目容易犯一个毛病——上来就画一个硕大的功能脑图从“用户协议”一直想到“推荐算法”最后啥都做不出来。二手交易系统的合理边界应该是前台能完成交易闭环后台能完成业务管控两端加起来覆盖“买卖双方管理员”三种角色的完整操作链。我当时给这个系统切的功能模块大概是这样的用户侧前台注册/登录、商品浏览与搜索、商品详情、发布闲置、购物车、下单与模拟支付、订单管理买家视角、商品收藏、个人资料修改。卖家侧同前台部分页面商品管理上架、下架、编辑、订单发货处理、收益查看。管理员侧后台用户管理禁用/启用、商品审核与下架、分类管理、订单总览、基础数据统计。这几块做完功能和论文量都刚好饱和。如果再往里加“论坛、私信、积分、秒杀”论文书写量会爆炸答辩时你也不一定讲得清如果不做后台项目又显得单薄答辩老师大概率会问“那管理功能怎么体现”1.2 技术选型为什么是Spring Boot MyBatis-Plus MySQL这个组合几乎是现在校园项目的默认答案但你要能说出“为什么是它”而不是只会说“大家都这么用”。Spring Boot把Tomcat内嵌、自动配置、依赖管理全部省掉开发效率高你写业务的时间能占比更大。课程设计本质是“在有限时间内完整呈现一个系统”框架要帮你节省时间而不是制造障碍。MyBatis-Plus我强烈推荐。它比原生MyBatis简单太多单表CRUD连SQL都不用写内置的LambdaQueryWrapper做动态筛选条件非常顺更关键的是有分页插件和代码生成器写课程设计能省下大量重复劳动。JPA虽然也很火但国内教程资料相对少出了问题排查成本高。MySQL经典关系型数据库强调约束、事务、索引这些概念放在课设里足够用了。版本我用的5.7和网上大部分教程兼容。开发环境上我用的是JDK 1.8有的老师要求JDK 17那就调成17Spring Boot版本相应选2.7.x或3.x但课程设计我还是建议卡在2.7.x资料最多踩坑经验最好找。1.3 前后端形态这个系统到底用不用分离这是我在做项目之前纠结最久的问题。最终我的结论是如果你目标是快速稳妥交差用Thymeleaf模板引擎如果你想在简历上有“前后端分离”四个字用Vue 接口分离。我这次选择的是后者因为答辩时“前端Vue 后端Spring Boot”听起来确实比“服务端渲染页面”占便宜。但你要有心理准备前后端分离意味着跨域、token校验、联调工作量全都会上来时间紧张的话很容易翻车。如果走Thymeleaf方案代码里就没有RestController那一堆JSON接口直接Controller返回视图模型用ModelAndView带参。功能上完全能做出来只是后端代码看起来“传统”一些。两条路我都走过后面部分内容我会以接口分离的方式为主来讲解这更贴近大多数人的项目结构。2. 数据库设计图书和漫画其实可以共用一套商品表2.1 核心表结构很多同学做二手书系统第一反应是建一张book表、一张comic表然后发现代码里到处都是重复逻辑。实际上书和漫画在交易系统里本质上是同一个东西——可售卖的商品区别只是分类、封面和个别属性。所以我的数据库设计里只建了一张商品表commodity通过category_id区分是书还是漫画。整体表的划分如下表名用途关键字段user用户表id, username, password, nickname, phone, avatar, role, statuscategory分类表id, name, parent_id, sortcommodity商品表书/漫画id, seller_id, category_id, title, author, publisher, isbn, cover, description, price, original_price, quality, status, view_countcommodity_image商品轮播图id, commodity_id, url, sortcart购物车id, user_id, commodity_id, quantity, checked, create_timeorder订单主表id, order_no, buyer_id, seller_id, total_amount, status, receiver_name, receiver_phone, receiver_addressorder_item订单明细id, order_id, commodity_id, commodity_title, commodity_cover, price, quantitycomment评价表id, order_id, from_user_id, to_user_id, content, ratingfavorite收藏表id, user_id, commodity_id, create_time2.2 关键字段的设计理由这里有几个字段设计经验直接关系到后面代码好不好写金额字段一定要用DECIMAL(10,2)不要用double。原因很简单浮点数在二进制里不能精确表示比如0.1加0.2会变成0.30000000000000004。交易系统里钱算错了是重大事故虽然课设没人真的付款但这个严谨性答辩老师会看。Java代码里对应使用BigDecimalMyBatis-Plus映射没任何问题。订单状态用数字类型tinyint配合枚举类做映射。很多新手直接把状态写成字符串比如pending、paid这样做前端展示倒是直白但数据库存储冗余、索引效率差而且状态容易写错。我用的枚举值是这样一套状态值含义后续操作0待付款可取消可支付1已付款/待发货卖家可发货2已发货买家可确认收货3已完成可评价4已取消关闭5退款中管理员介入清晰的状态机让代码里的判断逻辑非常干净比如只有状态为0的订单才能执行支付逻辑只有状态为1的订单才能执行发货逻辑。我一开始写这个系统时没有状态机概念把所有判断都靠if堆在Service里改一个字段牵一发动全身重构之后才清爽。逻辑删除字段deleted不要物理删数据。商品被用户删除后后台数据统计还要用到它物理删除会丢数据。MyBatis-Plus对逻辑删除有原生支持TableLogic注解加上去普通查询会自动带上deleted0条件。2.3 索引和冗余字段取舍数据库表的索引不要乱加但有几个必加commodity表status筛选上架商品、category_id分类查询、create_time新书排序。order表buyer_id、seller_id、status。order_item表order_id。comment表order_id唯一索引。在order_item里我冗余了commodity_title和commodity_cover这是故意这么做的。因为订单是交易快照商品标题和图片是下单那一刻的状态万一商品后来被改名、下架甚至删除订单明细必须保持原样。订单表不能随便关联查询商品表取名字这是交易系统的一个基本常识。这个设计点我答辩时候专门讲了老师印象分加了不少。3. 核心功能实现逻辑拆解从登录到下单的完整链路3.1 登录注册JWT和拦截器怎么搭用户注册登录这件事人人都写但很多人写完就完了根本没想过“为什么要有token”。这里把逻辑捋一遍HTTP是无状态的这次请求和下次请求没关系可系统得记住“你现在已经登录过了”所以需要在登录成功后给客户端发一个令牌客户端下次请求时把这个令牌带上服务器验证通过就知道你是谁。我用的是JWTJSON Web Token结构上分三块Header、Payload、Signature。服务端用jjwt库生成token登录成功把用户id和用户名放进去设置过期时间比如24小时。客户端拿到后存到localStorage之后每次请求在Authorization请求头里带回。后端拦截器处理起来是这个思路简化版代码public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录、注册、商品浏览等公开接口 String uri request.getRequestURI(); if (uri.startsWith(/api/auth/) || uri.startsWith(/api/public/)) { return true; } // 校验token String token request.getHeader(Authorization); try { Claims claims JwtUtil.parseToken(token); UserContext.setUserId(claims.get(userId, Integer.class)); return true; } catch (Exception e) { response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\请先登录\}); return false; } } }注意拦截器只解决“是否登录”的问题解决不了“是不是管理员”的问题。后台接口要再包一层角色判断我的做法是先请求拦截器解析出用户id再在Service层从数据库加载用户角色判断更省事的方案是直接用Spring Security但对课设来说引入一个完整安全框架有点重我当初选择了轻量方案拦截器做登录识别 自定义注解RequireAdmin做角色校验逻辑简单而且答辩能说清楚。3.2 发布商品与图片上传发布商品是卖家侧的核心操作。前端传过来的其实是一个multipart/form-data表单商品信息字段多张图片文件。后端接收时用MultipartFile[]接文件第一个文件作为封面存到cover字段其余文件存到commodity_image表。文件上传有个关键点一定不要把图片直接丢到项目的src/main/resources/static下然后写死相对路径存数据库否则项目打包成jar运行时路径全乱。正确方案是把文件保存到磁盘上一个独立上传目录比如D:/upload/Windows或者/var/upload/Linux然后给这个目录配置虚拟路径映射spring: servlet: multipart: max-file-size: 5MB max-request-size: 20MB web: resources: static-locations: classpath:/static/, file:${upload.path}/ upload: path: D:/upload/这样文件上传后数据库里只存一个相对路径URL比如/files/20250612/xxx.jpg浏览器直接通过项目地址访问这个URL就能看到图片换目录也只需要改配置文件非常稳。3.3 搜索筛选动态条件怎么写的商品列表页的筛选条件非常典型用户可能会选分类、选九成新、搜关键字、按价格区间筛也可能什么都不选直接看全部。这种需求就是MyBatis-Plus的LambdaQueryWrapper大显身手的时候用条件判断一步步拼LambdaQueryWrapperCommodity wrapper Wrappers.lambdaQuery(); // 关键字搜索匹配标题、作者、出版社 String keyword req.getKeyword(); if (StringUtils.hasText(keyword)) { wrapper.and(w - w.like(Commodity::getTitle, keyword) .or().like(Commodity::getAuthor, keyword) .or().like(Commodity::getPublisher, keyword)); } // 分类筛选 if (req.getCategoryId() ! null) { wrapper.eq(Commodity::getCategoryId, req.getCategoryId()); } // 成色筛选比如九成新对应quality9 if (req.getQuality() ! null) { wrapper.ge(Commodity::getQuality, req.getQuality()); } // 价格区间 if (req.getMinPrice() ! null) { wrapper.ge(Commodity::getPrice, req.getMinPrice()); } if (req.getMaxPrice() ! null) { wrapper.le(Commodity::getPrice, req.getMaxPrice()); } // 只显示上架中的商品按时间倒序 wrapper.eq(Commodity::getStatus, 1) .orderByDesc(Commodity::getCreateTime); PageCommodity page commodityMapper.selectPage(new Page(req.getPageNum(), req.getPageSize()), wrapper);搜索列表页的排序字段我额外做了个“综合排序”热度加权排序orderByDesc(Commodity::getViewCount, Commodity::getCreateTime)虽然实现很简单但页面质感会提升不少。3.4 下单与订单状态机下单是整个系统的核心环节逻辑顺序不能乱尤其注意“先校验再写库”这个原则校验商品是否存在且状态为“上架中”。商品可能刚好被卖家下架了要直接返回“商品已下架”。如果从购物车结算校验购物车条目是否都属于当前用户防止用户篡改ID购买别人的购物车商品。事务内生成order主记录和order_item明细订单号需要唯一。订单号生成我一开始用UUID但26位太长对日志不友好后来改成时间戳随机数的组合yyyyMMddHHmmss 4位随机数。课设场景下这个方案够了不过要提醒自己同一秒生成大量订单可能撞号严格场景应该用雪花算法。论文里我可以把这个改进方向写进去作为“系统不足与展望”。模拟支付的实现更简单但也要模拟真实流程下单那一刻订单状态是0待付款用户点“立即支付”后调一个paymentController接口里面不接任何真实支付通道直接做“扣款”动作并把状态改成1已付款/待发货同时给卖家生成一条待发货记录。答辩时你要能说清楚真实支付流程会走到微信/支付宝的网关、回调、验签课程设计用模拟支付是为了闭环业务链路而不是把复杂度堆在不必要的渠道对接上。3.5 购物车简化方案购物车看着复杂其实数据模型很简单一张cart表一个用户ID对应多条商品记录每行有一个checked字段标记是否选中。结算时查出user_id ? AND checked 1的所有记录批量生成一个主订单和多个明细订单。有个细节值得注意购物车里同一个商品如果被加入两次我不建议插入两条记录而是quantity加1结算时也要重新查一次商品状态万一商品下架了就自动跳过并在前端提示“部分商品已失效”。3.6 后台管理的权限控制管理员后台我没有单独建一套页面而是复用前端路由前端根据登录用户的role字段决定是否显示“后台管理”入口后端接口再用自定义注解校验。很多人的项目只做了前端隐藏按钮接口却谁都能调这是一个非常容易被攻击的点。前端的隐藏只是用户体验后端的校验才是安全底线。自定义注解的做法很简单定义一个RequireAdmin注解写一个拦截器或AOP切面在拦截器里从UserContext拿到当前用户id然后调用userService.getById(userId)查角色如果角色不是admin就返回403。注意这里不能只靠请求头里的信息判断因为用户可以伪造角色字段必须查库。后台的数据统计页面我用了ECharts画了个“近7天订单量折线图”和“分类销量占比饼图”数据靠SQL查询聚合——分组按DATE(create_time)统计订单数、按category_id联表统计销量。这个页面代码量不大但对答辩观感的提升非常明显老师看到图表会下意识觉得“这个系统做完整了”。4. 踩坑实录不跑一遍根本发现不了的五个问题这一节全是真金白银换来的教训。我自己的原则是项目踩坑不是坏事关键是踩坑后要知道为什么坑长这样、下次怎么绕开。下面这五个坑我敢说绝大多数做同类项目的人都遇到过。4.1 图片上传后一换环境就404这个问题几乎必踩。最初的代码我是这样写的把图片保存到IDEA工作目录的static/upload下数据库存/upload/xxx.jpg。在本地跑没有任何问题后来把项目打成jar包部署到服务器上一看图片全部404。原因很简单jar包内部的文件系统是只读的而且每次重新部署resources/static都会被清空重新写入。图片根本不是静态资源不应该放进classpath。后来我改用“配置文件指定外部磁盘目录 资源映射”方案无论项目部署到哪台机器只要配置了上传路径就能用。具体配置我在3.2节已经写了这里补充一句上线后这个外部路径一定要记得做备份数据库可以迁移文件目录忘了迁就等于图片全丢。4.2 MyBatis-Plus分页total一直是0用selectPage查列表时明明有数据page.getTotal()返回的却是0但getRecords()正常。第一次遇到这个问题我还以为是MyBatis-Plus的bug查了半天发现是分页拦截器没注册。MyBatis-Plus 3.4之后分页不能光靠引入依赖必须在配置类中显式添加分页插件Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }还有一个小坑分页插件必须放在拦截器链的最前面实际上通常也只加这一条。如果同时加了多租户、乐观锁等其他插件顺序写不对会导致分页不生效。4.3 LocalDateTime序列化出现“早上8点”问题实体类用LocalDateTime接收注册时间存库没问题但调用接口返回前端时JSON里的时间总是显示成当天早上8点而不是数据库中存储的日期。原因很隐晦JSON序列化LocalDateTime时如果系统时区是UTC而数据库时区是东八区序列化会多出8小时偏差。我的解决方法是两步走MySQL JDBC连接串加上serverTimezoneAsia/Shanghai参数保证驱动以国内时区解释时间字段。Spring Boot里配置Jackson的时间格式spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8两个都配上才稳。光配JDBC时区前端拿到的时间格式还是2025-06-12T10:30:00这种带T的ISO格式不好看光配Jackson格式数据库读出来的时间可能已经错乱。4.4 前端Vue分离开发时的跨域问题前端跑在8080端口后端跑在8081端口浏览器发请求会被跨域策略挡掉。这里有两种解决方案第一种是后端加全局CorsFilter配置允许的来源和请求头Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedHeader(*); config.addAllowedMethod(*); config.setAllowCredentials(true); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }第二种是前端用Vite或Webpack的代理把/api开头的请求转发到8081这样浏览器看到的是同源请求后端连跨域配置都不用加。我实际项目里两种方式都试过推荐开发阶段用代理部署时交给Nginx反代后端保持纯净。但要注意如果后端已经加了CorsFilter前端代理又转发一次某些场景下会出现Access-Control-Allow-Origin冲突记得二选一。4.5 表字段设计被老师点名的两个问题答辩前自查时我发现两处硬伤。第一个是用户表密码字段。最早存的居然是明文因为注册时偷懒没加密。老师问了一句“如果数据库泄露用户密码是不是全暴露了”我赶紧改成BCrypt哈希加密BCryptPasswordEncoder是Spring Security里现成的不用引入整个安全框架也能单独用。加密后的密码长这样$2a$10$...每次加盐随机即使两个用户的明文密码相同密文也不同。第二个是商品描述字段长度。一开始我用varchar(255)存商品介绍结果有人发布长介绍时后台直接报错SQL语句提示Data too long for column description后来改成TEXT类型才解决。这件事提醒我搞不清字段长度就用TEXT255的varchar放不了多少字。5. 给答辩加分的三个方向在不失控的前提下扩展如果时间充裕你可以在基础版本之上做几个“画龙点睛”的扩展。但一定记住扩展的前提是核心功能完全稳定千万别为了加分项把主流程搞崩。5.1 Redis缓存热点商品和验证码Spring Boot Redis在课设里出现频率很高但很多人只是“为了用而用”。我理解它的合理落地方式有两种首页热门商品缓存商品列表经常被访问但数据变化不频繁。第一次访问时从MySQL查出来放进Redis设置过期时间比如30分钟后续请求直接读缓存商品被下架或修改时主动删除缓存。这就是典型的缓存穿透问题预防——商品不存在时也要给一个空值缓存防止恶意请求反复打数据库。短信/邮箱验证码存储注册和找回密码时生成验证码存到Redis并设置5分钟过期比对后删除。这比把验证码存数据库更优雅天然支持过期。引入Redis意味着你需要在论文里写清楚缓存一致性的处理策略先更新数据库再删除缓存面试时这是个高频问题提前准备好。5.2 订单状态变化的站内消息通知订单从“已付款”变成“已发货”买家怎么知道我做的方案是一个简单的消息通知模块卖家发货时系统生成一条消息记录存进notice表买家前端轮询“未读消息数”有变化就弹提示。为什么要用“轮询”而不是WebSocket因为课程设计的体量撑不起长连接的复杂度轮询每30秒请求一次接口代码量小得多效果也符合预期。WebSocket可以把“实时性”做得更好但没必要为一个课设引入消息中间件我在论文里把WebSocket作为“系统改进方向”写出来反而更有得聊。5.3 报表把系统做得“有数据感”前面3.6节提过ECharts订单统计图。这里单独说要给一个“管理驾驶舱”页面今天的订单数、待发货数、本月成交额、热销分类TOP5。这些数据不用额外建表SQL聚合查询就行。但要注意聚合查询的SQL相对复杂我建议用MyBatis的XML自定义SQL而不是用LambdaQueryWrapper硬拼。5.4 文档结构不是凑字数是能自圆其说标题里带了“万字文档”那文档里到底该写点什么我按我自己项目配套文档的目录给你列一下照着填基本上答辩不会慌第一章 绪论背景、现状、意义、开发工具。第二章 需求分析用例图、功能性需求、非功能性需求。第三章 概要设计系统架构图、模块划分图、功能模块清单。第四章 详细设计数据库E-R图、表结构说明、核心类设计、关键流程时序图。第五章 系统实现每个核心功能模块的页面截图关键代码说明。第六章 系统测试测试用例表、边界测试结果。第七章 总结完成了什么、不足是什么、改进方向是什么。重点不要放在抄袭感很强的“背景意义”上而是放在第三、四、五章——这些才是老师会认真看的部分。数据库设计章节里把commodity表的“一书一漫画共用”思想讲清楚系统实现章节把订单状态机讲清楚这两段就是全文的硬骨头。6. 关于源码、数据库与跑通项目的最后说明很多人拿到项目源码第一件事就是导入IDEA、点运行然后报错然后放弃。我把我自己的运行步骤放在这照着走基本不会有问题先用Navicat创建数据库secondhand_book字符集选utf8mb4导入项目里的.sql文件。修改application.yml里的数据库用户名密码、上传路径、端口号。Maven依赖下载完毕后先启动后端看控制台日志有没有报错。再启动前端如果是Vue项目执行npm install然后npm run dev。用管理员账号登录后台用普通用户账号登录前台各测一遍完整流程。如果编译报错九成是JDK版本和项目不匹配如果登录报错检查user表里有没有预置账号我一般在数据库初始化脚本里放三个账号管理员、普通买家、普通卖家。另外提一个很多人忽视的点源码拿到手之后一定要自己跑通、自己看懂。答辩时老师随口问一句“你这个订单状态从2到3是哪段代码实现的”你要是答不上来项目做得再完整也会减分。我自己的做法是拿到别人的项目源码后先不管业务把每个Controller翻一遍然后对着数据库表反推字段关系最后再动手改几个功能点加一个自己的小需求——比如给商品列表加个“包邮”筛选——这样项目才算真正变成“你的”。二手书交易系统这个题目看起来简单但真正能把JWT鉴权、文件上传、订单状态机、后台权限、统计报表这五件事都讲清楚的人其实不多。功能不需要多把交易闭环走通、把每一个关键决策背后的理由想明白这就是一个能让你在答辩时站得住的课程设计。如果你现在正被这个题目搞得焦头烂额不妨从数据库表设计开始一步步推比急着写代码管用。
阅读完成 · 觉得有帮助?