1. 这个系统真正要解决的,是校园交易的信任与效率问题
做校园二手物品置换系统之前,我先把传统校园二手交易的整个流程走了一遍,才理解为什么很多同类项目做着做着就变成了"静态展示页"。
校园里最常见的二手交易场景是这样的:毕业生离校前在宿舍楼下贴海报,或者在QQ群、微信群里刷屏发消息,附上几张随手拍的照片和一句"便宜出,可小刀"。新生开学时想要淘点货,只能翻几百条聊天记录,看图片、猜成色、猜是否还在。看中一件商品后,双方私聊、约时间、线下见面、现场验货、转账、拿走。整个过程里,信息极度碎片化,价格不透明,也没有任何机制约束"放鸽子"和"到手刀"的行为。
这个系统的核心价值,并不是把"发消息"变成"发布商品",而是把校园内部零散、无结构、无信任依托的交换行为,沉淀到一个有规则、有约束、有记录的交易闭环里。
所以你去看市面上的毕设项目,凡是只做了商品发布、商品浏览、后台管理这三个模块的,基本都停留在"信息的陈列"阶段,没有真正触达交易的本质。一套完整的校园二手物品置换系统,至少要覆盖五个环节:物品信息结构化、精确检索与推荐、置换意向撮合、交易流程约束、信用与安全兜底。少了任何一个,用户就还是会回到QQ群里去完成交易,系统就成了摆设。
这篇博文里,我讲的是我基于SpringBoot 3.x实现这套系统的完整思路:为什么这样设计数据表、核心的置换流程怎么用状态机去控制、搜索模块从MySQL到全文检索的演进过程、以及交易安全模块落地时遇到的真实坑。项目适合正在做毕业设计、或者想深入了解一个完整业务项目如何从需求落地到代码的同学参考,这里面的很多经验,是看开源项目源码看不来的。
2. 功能边界与模块拆解:先想清楚系统要做什么,再谈技术选型
很多同学拿到这类题目,第一反应就是先建SpringBoot项目、加依赖、写Controller。这是一个非常危险的开始方式。我在给这套系统规划时,第一周全部在做需求澄清,一行代码没写。
2.1 用户角色和使用场景决定了系统必须有哪些功能
校园二手平台天然有两种角色、三种场景。两种角色是学生买家/卖家和系统管理员。三种场景分别是:日常浏览检索、发起置换与交易、后台运营管理。基于这"两角色三场景",系统边界就出来了。
学生在系统中能做的事情包括:注册登录与个人资料维护(绑定学号、学院信息)、发布闲置物品(多图上传、成色描述、期望置换物品或期望售价)、浏览商品列表与详情、关键词搜索与分类筛选、对心仪商品发起"置换意向"或直接下单购买、查看交易进度、确认收货后互相评价。管理员能做的事情包括:用户审核与封禁、商品审核上下架、分类管理、交易纠纷仲裁、系统公告维护。
需要注意的一个设计决策是,我刻意把"发布闲置"和"求购"做成了两个独立入口。很多同学会把它们合并成同一个表单,但实际使用中,发布闲置是"主动供给",发布求购是"被动接收",两者的信息结构不同、匹配逻辑也不同。求购信息需要单独被商家(其他学生)看到并主动响应,如果混在商品列表里,求购信息就会一直被闲置信息淹没,无法形成双向撮合。
2.2 模块划分要按"业务域"而不是按"技术层"来切
技术选型上,我用了SpringBoot 3.2.4、MyBatis-Plus 3.5.7、MySQL 8.0、Redis 7.0、MinIO做对象存储、WebSocket做站内信实时通知。为什么选这套组合,而不去追求更复杂的微服务体系?核心原因有三条。
第一,这是一个单体应用就能承载全部业务的中小型系统。预期并发量在百级到千级,单体架构的开发和部署成本最可控,排查问题也最简单。微服务拆分不会给你带来任何实际收益,反而会给项目增加分布式事务、服务调用的复杂度,这不是一个校园项目该背的包袱。
第二,MyBatis-Plus在单表CRUD上的开发效率极高,内置的分页插件、自定义填充、逻辑删除功能,能省掉大量样板代码。对于权限认证,我选择了Sa-Token而不是Spring Security,因为在这个场景里我们需要的是登录认证、角色鉴权、踢人下线这些轻量能力,Sa-Token的API设计更贴合这类业务,学习成本也低得多。
第三,Redis在这里不是摆设,承担了四个核心职责:用户会话Token存储、验证码存储、商品浏览量计数、热门搜索词排行。这些都是高频、短生命周期的数据,放MySQL里反而会增加不必要的磁盘IO。
模块划分上,我按照业务域拆成了8个核心模块:用户模块、商品模块、类目模块、置换/交易模块、评价模块、消息模块、搜索模块、后台管理模块。每个模块在代码中对应一个独立的包结构,领域边界清晰,后续扩展功能(比如引入积分体系)时不会牵一发动全身。
3. 数据库设计:一张订单表如何同时承载"出售"和"置换"两种模式
数据库设计是这类系统里最见功力的部分,它直接决定后续业务逻辑好不好写、SQL会不会越查越乱。我在设计时经历了一次较大的重构,从"出售和置换分离"改成了"统一交易模型",这个决策值得展开讲。
3.1 商品表的设计细节:状态字段是业务流转的基石
商品表(t_item)是整个系统的核心资产,设计了以下关键字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,使用雪花算法生成 |
| user_id | bigint | 发布者ID |
| title | varchar(100) | 商品标题 |
| description | text | 详细描述 |
| category_id | bigint | 分类ID |
| price | decimal(10,2) | 期望售价,兼容0元(表示只置换不出售) |
| want_item | varchar(255) | 期望置换的物品描述 |
| trade_mode | tinyint | 交易模式:1=出售,2=置换,3=出售且可置换 |
| status | tinyint | 状态:0=草稿,1=审核中,2=上架,3=下架,4=已置换/已售出 |
| view_count | int | 浏览量,Redis异步递增后定期落库 |
| province/city/district | varchar | 所在校区/楼栋区域(脱敏) |
这个表设计里最关键的字段是trade_mode。它决定了商品在列表页上以什么卡片形态展示,也决定了后端的交易逻辑走哪条分支。如果一件商品设置为"3",买家可以在详情页看到"立即购买"和"发起置换"两个按钮;如果设置为"2",就只能看到置换按钮。
商品状态机是另一个容易写崩的地方。我定义的状态流转规则是:草稿→审核中→上架→(下架/已售出),同时审核不通过时回到草稿状态。注意这里有个容易被忽略的约束:只有处于"上架"状态的商品才能被搜索到、才能被发起交易。很多项目会把"审核中"的商品也查出来展示,导致用户看到一件商品点进去却无法操作,体验非常差。
3.2 交易表的重构:为什么不区分"销售订单表"和"置换订单表"
我第一次设计时,老老实实建了sale_order表和swap_order表两张表,sale_order存买家ID、金额、收货地址,swap_order存发起方ID、接收方ID、双方商品ID。看起来合理,但写业务逻辑时会遇到一个棘手的问题:一个用户既是买家又是卖家,他可能同时有一笔卖出记录和一笔置换记录,个人交易记录的查询逻辑就要写两套SQL再合并,列表分页也麻烦,后台统计交易总额时计算逻辑更是绕。
重构方案是统一为交易表(t_trade),用trade_type字段区分"出售"和"置换",用trade_status字段驱动流程流转。
统一交易表的核心字段包括:
- initiator_id、receiver_id:发起方和接收方ID,出售模式下,initiator是买家,receiver是卖家
- initiator_item_id、receiver_item_id:发起方物品ID、接收方物品ID,出售模式下的receiver_item_id就是被购买的商品
- trade_type:1=出售,2=置换
- status:交易状态,1=待对方确认,2=双方已确认,3=交易完成,4=已取消
- deposit_type:是否使用平台担保(后面在安全模块详细讲)
这样设计之后,查询"我参与的订单"只需要一条SQL:SELECT * FROM t_trade WHERE initiator_id = ? OR receiver_id = ?。后台统计平台的交易规模也变得非常直接。
3.3 置换流程的状态流转:共识机制的轻量化实现
置换交易的本质,是双方对"用A物品换B物品"这个要约达成一致。我在设计时参考了合同法的要约承诺逻辑,拆成了两步确认,巧妙化解了单方确认带来的纠纷:
- 发起方(A学生)对B学生的商品发起置换申请,此时交易状态进入"待接收方确认"。
- B学生收到消息通知,可以"接受"或"拒绝"。接受后,交易状态变为"双方已确认";拒绝后直接关闭。
- 双方在线下完成实物交换后,在平台上点击"确认完成",交易状态变为"交易完成"。
这个流程里,最容易被忽视的是超时机制和撤销机制。接收方如果一直不确认,发起方就应该有撤销的权利——因为对方可能已经不想换了但不好意思拒绝,一直挂起会锁住发起方的商品。我用一个定时任务,每10分钟扫描一次超过24小时仍处于"待确认"状态的交易,自动发送提醒消息;超过48小时自动取消。这个细节看似微小,但对用户体验的提升是决定性的。
4. 核心业务逻辑落地:搜索、匹配和消息通知的实现思路
系统的骨架搭好之后,真正的难点在上层业务的实现。我挑三个最有代表性的模块来讲实现过程中遇到的坑和取舍。
4.1 搜索模块的演进:从MySQL LIKE到全文索引的过渡
商品搜索是用户最高频的操作。第一个版本我用MySQL的LIKE '%关键词%',在小数据量下测试没什么问题,但当商品表数据量到两万条以上、多关键词组合查询时,慢查询日志就开始报警了。
我把查询SQL拿出去explain看了一眼,全表扫描,走了filesort,单次查询平均耗时在800ms左右。这对于一个搜索接口来说是完全不可接受的。
当时有两个备选方案:一是上Elasticsearch,二是先用MySQL内置的全文索引过渡。考虑到SpringBoot整合Elasticsearch之后增加的运维复杂度(需要维护一个独立的ES集群),我选择了后者来验证搜索热词,后续如果你做的版本发布到公网、商品数据量突破十万条,再迁移到ES也不迟。MySQL的全文索引在InnoDB引擎下配合ngram分词器,对中文的支持是够用的。
具体实现是在建表时加上全文索引:
ALTER TABLE t_item ADD FULLTEXT INDEX ft_search (title, description) WITH PARSER ngram;查询时用MATCH...AGAINST语法,SpringBoot中MyBatis-Plus支持直接写自定义SQL:
<select id="searchByKeyword" resultType="ItemVO"> SELECT id, title, price, cover_image, MATCH(title, description) AGAINST (#{keyword}) AS score FROM t_item WHERE MATCH(title, description) AGAINST (#{keyword}) AND status = 2 ORDER BY score DESC LIMIT #{pageSize} OFFSET #{offset} </select>需要注意的一个细节是,建议保留LIKE查询作为全文索引的兜底。原因是全文索引对特殊符号和部分短词支持不理想,比如用户搜索"蓝牙耳机"这种词,ngram分词器可以正常切分为"蓝牙""牙耳""耳机",但搜索"AirPods"这种英文词时匹配逻辑会有偏差。用关键词白名单的方式,系统优先尝试全文索引,如果返回为空再fallback到LIKE查询,能显著提高搜索的成功率。
同时,我在搜索模块接入了Redis的ZSet结构做热搜词排行。每次用户搜索时,ZINCRBY hotwords:week 1 {keyword},后台定期取Top20的词汇,在前端页面渲染成"大家都在搜"标签。这个功能对转化率有实打实的提升。
4.2 商品推荐:基于类目偏好的冷启动方案
推荐系统的完整方案(协同过滤、用户向量等)对这个小项目来说过于铺张。我做的是一套基于用户实时行为的轻量推荐逻辑:当前用户浏览过的商品类目集合记为A,筛选出同分类下其他用户发布、当前用户未浏览过的商品,按照浏览量和发布时间进行加权排序,取前8条。
这里有个SpringBoot实现上的小技巧,值得单独说一下。用户的浏览历史存储在Redis的List结构里,LPUSH browse:history:{userId} {itemId},每次推送前先LRANGE取出最近浏览的50条商品ID,在MySQL查询时用NOT IN排除掉,避免给用户推他刚看过的商品。这个逻辑如果用纯SQL去查item_id NOT IN (子查询),在数据量大时性能堪忧,但结合Redis,消耗几乎可以忽略。
4.3 站内消息通知:WebSocket与待办提醒的无缝联动
交易过程中用户需要感知的节点很多,包括:置换申请发起成功、对方接受/拒绝了置换、订单完成提醒、被他人关注/收藏、后台审核结果通知。我用WebSocket做实时推送,同时在数据库里落一份消息记录。
SpringBoot 3.x整合WebSocket比之前简单了一些,核心步骤是三个:引入依赖、定义WebSocketConfig注册端点、写Handler处理收发。
@Configuration public class WebSocketConfig { @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }在用户登录时,后台将用户ID与WebSocket Session建立映射。当一笔交易产生新事件时,除了调用消息服务落库,还会通过session推给对端用户。这里有一个真实的坑要提醒:WebSocket在通过Nginx反向代理时,默认无法建立长连接,必须配置Upgrade头转发才能正常工作。配置如下:
location /ws/ { proxy_pass http://backend-server; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; }如果忘记了这段配置,本地测试一切正常,一旦部署到服务器上带域名访问,WebSocket就会一直握手失败。这类问题排查起来特别费时间,因为本地环境完全无法复现。
5. 置换流程与交易安全:我用担保交易机制解决了"谁先换谁先卖"的信任问题
校园置换里最尖锐的矛盾,不是信息不对称,而是信任缺失。两个人"我把货给你,你就不认账了怎么办?"。所以交易安全模块不是装饰品,是整个系统能不能真正被用起来的胜负手。
5.1 担保交易的简化实现:把平台的信用变成交易介质
我的方案是设计了一个"平台担保单"的概念。当双方确认交易后,系统强制为这笔交易生成一张担保单,记录交易ID、双方用户ID、双方商品快照。它不涉及真实资金的托管(所以不用去对接支付渠道),但它在业务语义上承担了"第三方见证"的角色——一旦产生纠纷,管理员可以通过担保单查看商品发布时的完整快照(图片、描述、价格),做出仲裁。
担保单的逻辑用状态机实现:
public enum GuaranteeStatus { PENDING, // 担保单生成,待双方确认实物交换 VERIFYING, // 一方发起完成确认,等待对方反确认 COMPLETED, // 双方确认,交易闭环 DISPUTED // 发生纠纷,管理员介入 }这里有一个非常容易踩坑的设计点:确认完成的动作必须双方都做,但极少数同学没注意到"先确认的人"和"后确认的人"在时间上可能相差几天。如果按照"第一方点击确认后立即置为COMPLETED",就会出现:A其实已经收到货但一直拖着不点确认,B明明早就把货给了却迟迟等不到交易闭环,评价权限也一直解锁不了。后来我把状态改成了"有一方确认后进入VERIFYING状态,另一方确认后变为COMPLETED",并且在用户待办里加了一条红色置顶提醒,这个纠纷率才真正降下来。
5.2 用户信用分:用简单的规则构建初步的社区自治
信用分模块听起来高大上,落地下来其实是一套累积加分/减分规则。我在用户表里加了一个credit_score字段,默认100分。规则如下:
- 完成一笔交易且双方互评5星,双方各加2分,上限150分
- 发布虚假商品被举报且核实,扣20分
- 发起置换申请后无故取消超过3次,扣5分/次
- 被管理员仲裁判责,扣10分
- 信用分低于60分的用户,发布商品功能将被禁用,只能浏览和购买
实现上就是一个SpringBoot的定时任务+事件监听器组合。每次交易状态变更为"COMPLETED"时,发布一个TradeCompletedEvent,信用分监听器消费事件,更新双方的积分。用Spring的事件机制而不是直接在交易Service里写积分逻辑,是为了避免交易主链路和信用计算互相耦合。
5.3 敏感词过滤与违禁品识别
校园二手平台最容易翻车的地方不是代码,而是内容合规。学生会上架各种奇怪的东西,比如一些不适合公开交易的物品。我在商品发布接口里接入了基于HanLP分词的自定义敏感词库,配合规则过滤双重校验,具体分三层:
- 第一层:基于HashMap的精确匹配,速度最快,拦截明显违规词
- 第二层:HanLP分词后对词向量做关键词模糊匹配,拦截变形绕过(比如"Q笔"这种谐音词)
- 第三层:人工审核兜底,新上架商品默认为"审核中",管理员在后台人工复核
需要提示的是,HanLP的词典需要自己持续维护扩展,一开始内置的词库肯定是覆盖不全的,我的做法是把每次人工审核时判定违规的商品标题自动提取关键词,回写到违规词库,让系统越用越准。
6. 从源码到可运行:SpringBoot项目的目录结构、配置和部署要点
最后一部分,我梳理一下整个项目的代码组织方式和部署时容易被忽略的问题。这套项目的完整目录结构如下:
campus-swap/ ├── src/main/java/com/campus/swap/ │ ├── controller/ # 接口层,RESTful API │ ├── service/ # 业务层 │ │ ├── impl/ # 业务实现 │ │ └── event/ # 领域事件 │ ├── mapper/ # MyBatis-Plus Mapper │ ├── entity/ # 数据库实体 │ ├── vo/ # 视图对象 │ ├── dto/ # 传入参数对象 │ ├── config/ # SpringBoot配置类 │ ├── common/ # 统一返回结果、异常处理、常量定义 │ └── utils/ # 工具类 ├── src/main/resources/ │ ├── mapper/ # MyBatis XML文件 │ ├── static/ # 前端静态资源 │ └── application.yml # 主配置文件 └── sql/ # 初始化建表SQL这个结构看着简单,但很多人在实际开发时会陷入一个误区:把所有类都塞到default包下。SpringBoot对包扫描有要求,实体类、Mapper、Service如果不在启动类所在包或其子包下,会注册不到容器里,运行时报No qualifying bean of type异常。这已经是老生常谈了,但每届都有同学踩。
6.1 application.yml配置中容易忽略的两个点
第一是日期格式化。SpringBoot默认的JSON序列化会把LocalDateTime输出成数组格式[2024, 5, 10, 14, 20, 30],前端拿到后根本没法用。必须在配置里全局指定:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8第二是文件上传大小限制。商品图片上传动不动就是两三张高清照片,SpringBoot默认的单个文件上传上限是1MB,不调配置的话,用户传一张手机照片就报MaxUploadSizeExceededException。这个报错信息不够友好,最好配合全局异常处理器,返回给前端具体的提示文案。
6.2 Docker部署时遇到的版本兼容坑
部署方案我选择的是宝塔面板+Docker Compose。这块有一个比较典型的坑,值得记录一下:SpringBoot 3.x要求JDK 17及以上,如果你的服务器上同时跑着别的老项目(JDK 8),直接编译会报"无法将类 X 转换为类 Y"这种诡异错误。我当时在这个问题上卡了将近两个小时,日志里报的错完全不指向版本问题,后来把编译环境的JAVA_HOME指到JDK 17之后才正常。
Docker Compose编排了三个服务:MySQL 8.0、Redis 7.0、MinIO。Compose文件的核心片段如下:
services: mysql: image: mysql:8.0 container_name: swap-mysql environment: - MYSQL_ROOT_PASSWORD=${DB_PASSWORD} - MYSQL_DATABASE=campus_swap volumes: - ./mysql_data:/var/lib/mysql - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql ports: - "3306:3306" redis: image: redis:7.0 container_name: swap-redis ports: - "6379:6379" volumes: - ./redis_data:/data minio: image: minio/minio container_name: swap-minio command: server /data --console-address ":9001" environment: - MINIO_ROOT_USER=${MINIO_USER} - MINIO_ROOT_PASSWORD=${MINIO_PASSWORD} ports: - "9000:9000" - "9001:9001"MinIO在容器中的数据持久化必须挂载本地卷,否则容器重启图片全部丢失。这个教训来自我自己的真实经历,在做压力测试时重启了容器,发现所有用户上传的图片都变成了默认占位图。文件数据不同于MySQL中的结构化数据,不会自动做事务性恢复,挂载磁盘卷是最稳妥的方式。
6.3 启动项目的完整步骤回顾
如果你要自己跑一遍这个项目,操作顺序建议是:
- 用
docker compose up -d启动MySQL、Redis、MinIO三个基础服务 - 执行
sql/init.sql脚本初始化数据库表结构和基础数据(管理员账号、初始分类) - 在MinIO控制台创建
campus-swap桶,并把桶权限设置为私有 - 修改
application.yml中的数据库连接、Redis连接、MinIO的Endpoint和AccessKey - 启动SpringBoot应用,访问
/doc.html查看接口文档 - 用管理员账号登录后台,把"系统参数设置"里的审核开关打开
这个顺序有一个为什么要这么排的理由:必须先有Excel或SQL脚本执行后的基础数据,应用启动时才会走得顺。比如用户注册需要校验分类ID是否存在,如果你的分类表是空的,用户注册和商品发布接口都会因为外键约束直接报错。
7. 一次完整的线上故障排查:为什么商品列表只有第一页有数据
我最后分享一个真实的排查经历,它暴露出的问题非常典型,对做这类基于SpringBoot的业务系统很有参考价值。
上线运行一段时间后,用户反馈搜索列表第二页一直是空的。第一页正常展示,点第二页马上就空白。这个现象指向性很强,要么是SQL的LIMIT/OFFSET有问题,要么是分页插件配置被人为干扰了。
第一步,我拉出接口请求,看到参数是page=2&size=10,后端接收后调用MyBatis-Plus的分页查询,按理说不会出问题。第二步,我打印实际执行的SQL,发现LIMIT偏移被算成了10,但查询结果返回了空集合。一个很自然的猜测就是OFFSET计算错误——比如前端传的page从1开始,后端在计算时又减了1,导致第二页实际查询的是LIMIT 10, 10变成了LIMIT 0, 10,访问的还是第一页的数据。
但调试完之后,我确认后端的分页偏移计算完全正常。第三步,我注意到打印出来的SQL里多了一个没有见过的条件:status = 2 AND status = 2。两个同样的条件不可能同时存在,这让我想到可能是MyBatis-Plus的@TableLogic逻辑删除字段和我在XML里手写的条件叠加产生了重复拼接。查了一圈之后,最终定位到了问题根源:我在实体类的status字段上加了逻辑删除注解@TableLogic,但这个字段本身是正常业务字段,不是逻辑删除标识。
这是MyBatis-Plus使用中一个非常隐蔽的坑。逻辑删除字段应该单独设计一个deleted字段,用0和1标识是否删除。而我错把业务里的商品上下架状态字段直接当成了逻辑删除字段,最终导致MyBatis-Plus在生成SQL时自动追加了AND deleted = 0的条件——而这里的deleted对应的其实是status字段的值为0,于是第一页的数据因为筛选后还有剩余所以正常,第二页商品大多处于"上架中"(status=2),全被这个隐式条件过滤掉了。
修复方式很简单,在实体类中单独增加deleted字段,并把@TableLogic注解移到它上面;status字段恢复为普通的业务字段。这个排查思路如果你以后遇到类似问题也可以参考:当SQL中出现了你没有写过的条件时,优先检查是不是ORM框架的自动机制动了手脚,而不要急着怀疑分页算法。
整个系统开发下来,我的最大体会是:这类校园业务项目,难点从来不是SpringBoot本身,而是对业务规则的理解和对细节的把握。一个交易流程的状态机设计清楚了,一个数据库表的结果集收敛了,一个逻辑删除的坑定位了,项目的完成度就立起来了。如果这篇文章能帮你跳过我在这些地方浪费的时间,那这个分享就算没有白写。