ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

校园二手交易系统实战:数据库设计、商品状态机与并发防重

校园二手交易系统实战:数据库设计、商品状态机与并发防重

1. 这套系统真正难的不是写代码,而是把"二手"这件事跑通

先说说我拿到这个题目时的第一反应。校园二手平台交易系统,看起来是个再普通不过的课设/毕设选题,网上一搜一大把,源码、开题报告、答辩PPT打包卖的那种。但真让我从零搭一遍,或者拿别人的源码改一遍交付,我才发现这里面的坑远比"CRUD 增删改查"要深得多。

这个系统的本质是什么?它是一个小范围、高密度、强信任关系的 C2C 交易场所。跟闲鱼、转转那类面向全社会的大平台相比,它的用户群体极度集中——就在一个校园里,卖家可能是隔壁宿舍楼的同学,买家可能是同一个社团的熟人。这种特性决定了它的技术选型和功能重心跟大平台完全不同:你不需要做复杂的风控模型、不需要做跨城物流对接、不需要处理千万级并发的秒杀,但你必须把身份认证、商品状态流转、交易凭证留痕、纠纷可追溯这几件事做扎实。

这篇内容适合谁看?如果你是正在做这个题目的学生,它能帮你搞清楚开题报告里那些"系统功能模块图"背后到底意味着什么代码;如果你是接了类似项目要交付的人,它能帮你避开我踩过的那些数据一致性坑;如果你只是想拿这套源码跑起来改改应付答辩,那我建议你至少把第 3 节和第 5 节看完,因为那两块是最容易被老师追问、也最容易在演示时翻车的地方。

我用一套完整的实战路径来讲:从需求怎么落地成表结构,到核心交易状态机怎么设计,再到源码里哪些地方必须改、哪些地方可以直接用,最后是我实际部署和演示时遇到的一堆意外情况。所有细节都基于这类项目的常见实践来补充,你可以直接抄作业,也可以按自己的场景调整。

2. 开题报告之外的隐藏需求:把功能表翻译成数据库表

2.1 五类角色不是五个用户表,而是权限维度

几乎所有这个题目的开题报告里都会画那么一张图:普通用户、卖家、买家、管理员、审核员。新手最容易犯的错误就是照着这张图建五张用户表,然后在代码里写一堆 if-else 判断当前用户是哪种角色。我见过不止一个源码包是这么干的,改起来极其痛苦。

正确的思路是单用户表 + 角色字段 + 权限表。一个用户在注册时只是"普通用户",当他发布第一件商品时,他自然就有了"卖家"身份;当他下单购买时,他又成了"买家"。角色是动态的、可叠加的,不是固定归属的。数据库设计上,我用一张user表存基础信息,一张role表存角色定义,中间用user_role关联表做多对多映射。

CREATE TABLE `user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `student_no` VARCHAR(20) NOT NULL UNIQUE COMMENT '学号,校园场景的唯一身份锚点', `nickname` VARCHAR(32) NOT NULL, `phone` VARCHAR(20) DEFAULT NULL, `password_hash` VARCHAR(128) NOT NULL, `credit_score` INT DEFAULT 100 COMMENT '信用分,初始100', `status` TINYINT DEFAULT 1 COMMENT '1正常 0封禁', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE `user_role` ( `user_id` BIGINT NOT NULL, `role_id` INT NOT NULL, PRIMARY KEY (`user_id`, `role_id`) );

这里有个关键设计点:student_no是这套系统的信任基石。校园场景区别于社会平台的最大优势就是实名可控,学号唯一的约束保证了"一个人只能有一个账号",后续所有纠纷追溯、信用累计都挂在这个锚点上。很多源码为了省事直接用手机号或邮箱注册,结果被人注册一堆小号刷单、恶意下单,这在校园场景里是完全不能接受的。

注意:学号字段一定要加唯一索引,并且在注册环节做格式校验(比如某校学号是10位纯数字)。我见过有源码用正则^\d{10}$卡这个,虽然死板但有效。

2.2 商品表的字段取舍:为什么我砍掉了"浏览量"

商品表是这套系统的核心,但很多开题报告里的字段设计要么过于臃肿,要么漏掉了关键字段。我列一下实际落地时我保留和砍掉的字段,并说明理由。

字段是否保留设计理由
title保留搜索主键,限制 30 字以内,避免列表页撑爆
description保留长文本,用 TEXT 类型,前端做折叠
price保留用 DECIMAL(10,2),绝不能用 FLOAT,金额精度问题会要命
original_price保留用于展示"原价划线",提升转化
category_id保留分类外键,对应分类表
condition_level保留成色枚举(全新/九成新/七成新/有瑕疵),校园二手核心决策依据
image_urls保留存 JSON 数组,一商品多图,最多 9 张
seller_id保留关联发布者
status保留商品状态机,见第 3 节,这是核心
view_count砍掉高频写操作,每次浏览都 UPDATE 会锁行,改成 Redis 计数或干脆不做
favorite_count砍掉同上,用关联表 COUNT 查询即可,不必冗余字段

price那个点我要多说两句。我实测过用FLOAT存价格做累加,几笔订单之后总价会出现19.999999这种东西,最后对账对不上。DECIMAL(10,2)才是金额该用的类型,MySQL 里它是精确存储的。这个细节在答辩时被问到,是能加分的。

condition_level用枚举而不是自由文本,是因为前端要按成色筛选,自由文本根本没法做聚合查询。我见过源码把它设计成 VARCHAR 让卖家自己填,结果列表里出现"还行""凑合""八成新左右"各种奇葩值,筛选功能形同虚设。

2.3 订单表与交易流水:一张表撑不起纠纷追溯

订单表的设计是整个系统里最需要想清楚的地方。新手常犯的错是只建一张order表,字段就是商品ID、买家ID、金额、状态。这样做的后果是:一旦买卖双方对交易过程有争议,你根本拿不出证据。

我的做法是订单主表 + 状态变更日志表双表结构。主表存当前快照,日志表存每一次状态跃迁的时间、操作人、备注。这个设计在校园场景里尤其重要,因为线下当面交易之后,双方很可能因为"东西到底有没有给"扯皮,有了日志就能还原整个时间线。

CREATE TABLE `trade_order` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL UNIQUE COMMENT '业务单号,日期+随机串', `goods_id` BIGINT NOT NULL, `buyer_id` BIGINT NOT NULL, `seller_id` BIGINT NOT NULL, `amount` DECIMAL(10,2) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0, `trade_type` TINYINT DEFAULT 1 COMMENT '1线下自提 2校内代收点', `remark` VARCHAR(255) DEFAULT NULL, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE `order_status_log` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_id` BIGINT NOT NULL, `from_status` TINYINT, `to_status` TINYINT NOT NULL, `operator_id` BIGINT NOT NULL, `operator_role` VARCHAR(16) NOT NULL, `note` VARCHAR(255), `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX `idx_order` (`order_id`) );

order_no用业务单号而不是直接暴露自增 ID,有两个好处:一是防止有人通过递增 ID 猜测订单量、爬取数据;二是线下沟通时念单号比念一长串数字方便。生成规则我用的是yyyyMMdd+ 6 位随机数,配合唯一索引冲突重试,简单够用。

3. 商品状态机:整套系统最容易埋雷的地方

3.1 为什么状态用整数枚举而不是字符串

先讲一个我踩过的真实坑。早期版本我把商品状态设计成字符串:on_sale、locked、sold、off_shelf。看起来可读性好,但问题出在两处:一是数据库里做状态统计时GROUP BY字符串性能差一截,索引效率也低;二是前后端联调时,前端传了个onsale(少了横杠),后端没校验就写进去了,结果商品在列表页显示为"未知状态",排查了半天。

后来我改成整数枚举,并配一张字典表或者直接用常量类维护:

状态值含义允许的下一步操作
0待审核审核通过 → 1;审核驳回 → 5
1在售下单锁定 → 2;卖家下架 → 4
2已锁定支付确认 → 3;超时/取消 → 1
3已成交终态
4已下架卖家可重新上架 → 1
5审核驳回修改后重新提交 → 0

这张表我建议你直接贴到开题报告的系统设计章节里,因为它把"状态流转图"讲得比任何流程图都清楚。老师问"商品被下单之后还能被别人买吗",你指着这张表答"状态从 1 变 2,列表页查询条件限定status = 1,所以查不到",干净利落。

3.2 并发下单的锁:一次真实的重复售卖事故

状态机设计好了不代表没问题,真正的考验在并发。我记得有次测试,让两个账号同时对同一件商品下单,结果两个订单都创建成功了,商品被卖了两次。原因很简单:我的代码是先SELECT查状态,判断是 1,然后UPDATE改成 2,再INSERT订单。两个请求同时进来,都查到了 1,都去改了,都成功了。

修复方案我试过三种,最后选了最稳的那种。

第一种是给查询加FOR UPDATE悲观锁,在事务里SELECT ... FOR UPDATE。可行,但锁的范围大,商品详情页的普通查询如果也走这个逻辑会影响性能。第二种是用 Redis 分布式锁,对校园这种单机部署的场景来说属于杀鸡用牛刀,还引入了额外的失效风险。

我最终用的是乐观锁 + 条件更新,一条 SQL 解决问题:

UPDATE goods SET status = 2, version = version + 1 WHERE id = #{goodsId} AND status = 1 AND version = #{version};

执行后判断受影响行数,如果是 0 就说明被别人抢先了,直接返回"手慢了,商品已被下单"。这个方案不需要额外组件,也没有长时间持锁,配合数据库行锁的原子性,在校园这种并发量下完全够用。我在goods表里加了version字段,所有状态变更都走这套逻辑。

实操心得:乐观锁的重试次数不要设太多,我这套系统里干脆不重试,直接失败让用户重新操作。二手商品本来就是"手快有手慢无",失败提示反而符合真实交易心理。

3.3 超时释放:定时任务还是延迟队列

订单创建后如果买家不付款,商品不能一直锁着。这里有个设计选择:用定时任务扫表,还是用延迟队列。

定时任务简单,@Scheduled(cron = "0 */1 * * * ?")每分钟扫一次超时未支付的订单,批量改回在售状态。缺点是精度只有分钟级,且扫表有一定压力。延迟队列(RabbitMQ 的 TTL + 死信、或 Redis 的 ZSet 轮询)精度高,但需要额外中间件。

我的选择是定时任务,理由很实在:校园场景的并发量撑死几千单,每分钟扫一次毫无压力,而引入 MQ 会让部署复杂度上升一个档次,答辩环境未必装得起来。这是一次典型的"用简单方案解决简单问题"的取舍,没必要为了显得高级而上重组件。

实现上有个细节要注意:扫表时不能一次全捞出来,要分批,LIMIT 200循环处理。而且更新状态时同样要走乐观锁条件更新,防止和用户的手动取消操作打架。

4. 源码拿到手之后,哪些必须改,哪些能直接用

4.1 先跑通,再动刀:定位项目结构的三个入口

拿到一份二手平台源码,不要急着改功能。先花半小时把项目跑起来,这半小时能帮你建立对整个项目的"地图感"。我通常从三个入口切入。

第一个是启动类。看@SpringBootApplication在哪,application.yml里配的数据库、端口、文件上传路径是什么。这一步能立刻判断出项目用的是 Spring Boot 几版、有没有集成本地文件存储还是走了对象存储。

第二个是控制器的路由清单。把所有@RequestMapping的路径扫一遍,画出一张接口表。这张表就是你理解业务范围的坐标系,比看开题报告的功能图准确得多。我扫完一般会标出来哪些接口是核心(商品、订单、用户),哪些是边角(公告、轮播图)。

第三个是数据库初始化脚本。找schema.sql或者项目根目录下的.sql文件,导进本地库看表结构。这一步最有价值——源码里可能没有 README,但表结构不会骗人,字段就是作者对业务理解的直接体现。

三个入口过完,你就知道这份源码的完成度大概在什么水平了。我遇到过的源码大致分三档:功能基本完整能跑的(约三成)、跑得起来但缺关键逻辑的(约五成)、根本跑不起来的(约两成)。第三档最常见的问题是依赖版本冲突和 SQL 脚本缺表。

4.2 那些一看就必须动的代码

有些代码不管你后续怎么改,都必须先修掉,因为它们要么有安全风险,要么会让演示翻车。

登录密码明文存储。这是我最常见到的偷懒写法,password字段直接存明文。必须改成 BCrypt 哈希,改动量很小,加依赖、注册时encode、登录时matches就完事,但答辩时老师如果点开数据库看到明文,印象分会掉一大截。

文件上传没做类型和大小校验。源码里常见的是MultipartFile直接存本地,文件名沿用原名。这有两个隐患:一是别人传个.jsp或.html上来,如果存储目录在 web 可访问路径下,就有被利用的风险;二是大文件直接把磁盘怼满。我的改法是:白名单校验扩展名(只允许 jpg、png、webp 三种)、限制单文件 5MB、重命名为 UUID 存储、存储目录放在项目外的独立路径。

越权访问没有任何校验。很多源码的控制层方法只判断"登录了没有",不判断"这个数据是不是你的"。表现就是改个 URL 里的 ID 就能看到别人的订单。修复思路是每个涉及资源的接口,都要在服务层校验当前登录用户ID与资源归属者ID是否一致,管理员角色除外。

// 典型的越权漏洞写法 Order order = orderMapper.selectById(orderId); return Result.ok(order); // 正确写法 Order order = orderMapper.selectById(orderId); Long currentUserId = SecurityUtil.getCurrentUserId(); if (!order.getBuyerId().equals(currentUserId) && !order.getSellerId().equals(currentUserId) && !SecurityUtil.hasRole("ADMIN")) { throw new BizException("无权访问该订单"); }

这三处改完,源码的"底线质量"就上来了。我一般把这三项作为交付前的必检项,哪怕功能一点不改也要先修好。

4.3 可以放心沿用的部分

也不是所有东西都要重写。有几块源码通常质量还可以,直接沿用能省大量时间。

图片上传后的前端预览组件、分页查询的通用封装、统一返回结果类(Result<T>)这几样,多数源码写得都能用。分类表的数据结构也基本没问题,二手商品的分类就那么几类:教材书籍、数码电子、生活用品、运动器材、服饰鞋帽、其他。直接初始化进去即可,不用纠结。

前端如果是 Vue 或原生 HTML 模板,页面布局可以留着,改改配色和 logo 就是一套新的。别小看这一步,从零写一套商品列表页、详情页、订单页的时间,够你把后端逻辑改三遍了。

5. 校园场景特有的三个功能点,别跳过

5.1 面交地点与时间约定:一个被低估的体验设计

社会平台的二手交易靠快递,校园交易靠面交。这个差异意味着你的订单里必须有"约定见面"的信息承载。我在订单表里加了trade_type和关联的约定表,允许买家下单时选择一个校内地点(食堂门口、图书馆一楼、宿舍区快递点),并填写期望时间段。

这个功能看起来很小,但它是我演示时老师最感兴趣的一块,因为它体现了"针对校园场景做设计"的思考,而不是照搬社会平台。实现上也很简单,一张地点字典表 + 订单上的两个冗余字段(地点ID、约定时间)就够了。地点字典可以由管理员维护,也可以直接从现有数据初始化几十条。

5.2 信用分机制:让爽约的人付出代价

校园二手最大的痛点是爽约。约好了八点图书馆见,人不去。平台如果没有约束手段,风气很快就烂掉。我的方案是做一个轻量信用分体系:初始 100 分,订单成交加 1 分,买家取消已锁定订单扣 5 分,卖家在商品锁定后无理由下架扣 5 分,被投诉核实成立扣 20 分。低于 60 分的账号限制发布和下单。

这套逻辑不复杂,但价值很高。答辩时你可以讲清楚"为什么校园平台比社会平台更有条件做信用体系"——因为用户在真实校园里,身份有学号锚定,失信成本远高于匿名平台。这个论点能撑起你开题报告里"研究意义"那一节。

实现上就是那张order_status_log表派上用场的地方:所有扣分操作都必须有日志记录,扣分原因可追溯。信用分的变更我单独建了一张credit_log表,记录变更前后值、原因、关联订单。

5.3 举报与审核闭环:不能只有入口没有出口

几乎所有这个题目的源码都有"举报"按钮,但十有八九点了没反应,或者只是往表里插一条记录就没下文了。这是典型的"功能图上有、实际没做完"。

一个能用的举报闭环至少需要三样东西:举报表(举报人、被举报对象类型、对象ID、理由、状态)、管理端审核列表(待处理、已处理)、处理结果反馈(给举报人一条站内通知)。状态流转是:待处理 → 已受理 → 已处理(下架商品/警告用户/封禁账号)或驳回。

这块我建议务必做完整,因为答辩演示时老师最爱点"举报"和"审核"这两个按钮,做不完整当场就露馅。工作量其实不大,一张表、两个接口、一个管理页面,一天能搞定。

6. 部署与演示那天,我遇到的三个意外

6.1 图片路径在换机器后全部失效

第一次部署到答辩用的机器上,所有商品图片显示为裂图。原因是我在数据库里存的是绝对路径D:/project/upload/xxx.jpg,换台机器路径根本不存在。这个坑非常经典。

修复思路是数据库只存相对路径或文件名,访问路径由配置项拼出来。比如库里存goods/2024/06/uuid.jpg,配置文件里写file.access-prefix=/upload/,前端拿到的完整 URL 由后端拼接返回。这样迁移机器只需要改一个配置项,数据完全不用动。

同理,上传目录也必须在配置文件里可配,别硬编码在代码里。我当时就是硬编码的,换机器时找了好久才定位到问题。

6.2 定时任务在服务器上没生效

本地测试时超时释放订单的定时任务跑得好好的,部署到服务器上就不动了。排查下来是两个原因叠加:一是服务器时区是 UTC,我的 cron 表达式按本地时间写的,差 8 小时;二是部署方式是打 jar 包用nohup跑,我一度以为是进程被杀了,其实是任务执行了但一直判断"未超时"。

修复很简单,启动参数里加-Duser.timezone=Asia/Shanghai,或者在配置文件里显式指定时区。这个细节建议你在答辩前一定要验证一遍,因为演示时如果现场演示"下单不付款,等一分钟状态自动回滚",任务不生效就直接尴尬了。

6.3 演示数据太干净反而不好看

我第一次演示前把数据库清空,只留了几条自己录的数据。结果老师问"你这个列表页翻页效果怎么样",一共三条数据,翻不了页。

后来我写了个脚本批量生成演示数据:50 个用户、300 件商品、80 条订单,商品标题和描述用模板随机组合,图片统一用几张占位图循环。这样演示时列表页有内容、分页有效果、筛选有结果、订单状态各种都有,看起来就像个真实在运营的系统。

提醒:演示数据里的用户昵称、商品标题不要用真实姓名和真实物品描述,用"某同学""九成新教材一本"这类中性表述即可,避免任何可能引起误解的信息。

7. 关于这套源码,我个人最想说的一点体会

做这个题目的人很多,绝大多数止步于"功能能跑通、答辩能过"。但我在实际把它改到能用的过程中发现,真正区分一套系统好坏的分水岭,从来不是功能数量,而是那些没人要求你做、但不做就会出事的地方——价格字段用不用 DECIMAL、状态变更有没有日志、并发下单有没有条件更新、越权访问有没有校验。这些点不会出现在开题报告的功能模块图里,但它们决定了这套系统是"能演示的作业"还是"能上线的作品"。

如果你时间有限,我的建议是:功能模块砍掉一两个没关系(比如收藏、评论可以简化),但第 3 节的状态机和第 4.2 节的三处底线修复,一个都不能省。这两块是真正体现你理解了这个业务的地方,也是答辩时最能拉开差距的地方。

至于开题报告,把第 2 节的表设计、第 3 节的状态流转表、第 5 节的三个校园特色功能写进去,配上系统架构图和 E-R 图,内容量就足够了,比你堆一堆"国内外研究现状"的空话要有说服力得多。数据库脚本和核心代码整理好附在附录里,这份东西的完整度就超过大部分同类交付了。

RELATED READING

延伸阅读

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