基于微信小程序的校园二手交易平台源码与数据库设计 简介这是一套面向计算机相关专业学生与项目实战学习者的校园二手交易平台微信小程序源码源自个人大四毕业设计经导师指导并获评审99分认可代码完整可运行适合作为毕设参考、课程设计或期末大作业的实践素材。资源包共258个文件整体约3.68MB以104个png与18个jpg界面素材、42个Java后端代码、20个js与13个vue前端逻辑、12个wxss与11个wxml页面结构为主另含sql建库脚本、json配置、xml映射及properties等文件覆盖小程序前端与后端接口的完整链路。目前已有135人学习下载。读者可据此掌握二手商品发布、浏览、交易等核心模块的实现思路理解前后端数据交互与数据库表结构设计并借助清晰的目录组织快速定位页面、接口与配置代码为毕业设计选题、答辩演示及项目实战练习提供可直接复用的参考方案。1. 校园二手交易平台从“跳蚤市场”到“可交付源码”的距离每年毕业季校园里都会出现一种很割裂的场景一边是学长学姐把还能用的显示器、自行车、考研书堆在楼道里“给钱就卖”另一边是学弟学妹在群里刷屏求购信息却永远对不上。这个供需两旺但匹配效率极低的场景正是校园二手交易平台存在的理由。而“基于微信小程序的校园二手交易平台源码数据库”这个标题本质上是在问一件事能不能用一套可运行、可改、可写进毕业设计文档的代码把这个场景跑通。它适合两类人一类是需要一个完整项目撑起毕业设计的技术学生另一类是想练手小程序全栈开发、但不想从零造轮子的开发者。核心难点不在“写页面”而在数据库设计、交易状态流转和审核机制这三块——它们决定了这套源码是能演示的玩具还是能讲清楚业务逻辑的作品。2. 先想清楚数据模型二手交易不是普通电商2.1 为什么不能照搬电商的“订单-商品”两张表很多人做校园二手平台第一反应是套用标准电商模型用户表、商品表、订单表三张表打天下。跑起来确实快但一遇到真实校园场景就翻车。校园二手和普通电商最大的区别是商品是单件、非标、且高度依赖线下交付。一件二手自行车只有一个卖掉就没了买家下单前往往要问“能不能便宜”“车在哪栋楼”交易完成后双方可能还要互相评价。如果只有订单表你没法记录“商品当前是否已被锁定”“买卖双方约定的交付地点”“议价过程”这些信息。我一般会把核心表拆成五张user用户、goods商品、order订单、message站内消息、review评价。其中goods表要有一个status字段取值至少包括on_sale在售、locked已被下单待交付、sold已售出、off_shelf下架。这个状态机是后面所有业务逻辑的根设计错了后面全是补丁。2.2 建表 SQL 与字段说明下面是一套可以直接在 MySQL 5.7 或 8.0 里执行的建表语句字段命名偏保守方便你后续改-- 用户表微信小程序登录后用 openid 做唯一标识 CREATE TABLE user ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, openid VARCHAR(64) NOT NULL COMMENT 微信 openid登录凭证, nickname VARCHAR(64) DEFAULT COMMENT 昵称, avatar VARCHAR(255) DEFAULT COMMENT 头像地址, phone VARCHAR(20) DEFAULT COMMENT 联系方式选填, credit INT DEFAULT 100 COMMENT 信用分初始 100, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_openid (openid) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; -- 商品表核心是 status 和 seller_id CREATE TABLE goods ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, seller_id INT UNSIGNED NOT NULL COMMENT 卖家 user.id, title VARCHAR(100) NOT NULL COMMENT 商品标题, description TEXT COMMENT 详细描述, price DECIMAL(10,2) NOT NULL COMMENT 售价, original_price DECIMAL(10,2) DEFAULT NULL COMMENT 原价用于展示折扣, category VARCHAR(32) NOT NULL COMMENT 分类教材/数码/生活/其他, images VARCHAR(1000) DEFAULT COMMENT 图片地址逗号分隔, status ENUM(on_sale,locked,sold,off_shelf) DEFAULT on_sale, location VARCHAR(100) DEFAULT COMMENT 交付地点如“三号宿舍楼”, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_seller (seller_id), KEY idx_status_category (status, category) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品表; -- 订单表记录交易状态流转 CREATE TABLE order ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, goods_id INT UNSIGNED NOT NULL, buyer_id INT UNSIGNED NOT NULL, seller_id INT UNSIGNED NOT NULL, amount DECIMAL(10,2) NOT NULL COMMENT 成交价可能议价后低于标价, status ENUM(created,confirmed,finished,cancelled) DEFAULT created, remark VARCHAR(255) DEFAULT COMMENT 买家留言, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, finish_time DATETIME DEFAULT NULL, PRIMARY KEY (id), KEY idx_buyer (buyer_id), KEY idx_goods (goods_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表;逻辑说明goods.status和order.status是两套状态不要混。商品被下单后goods.status从on_sale变成locked订单完成后变成sold。如果订单取消商品要回滚到on_sale。这个回滚逻辑必须写在事务里否则会出现“订单取消了但商品还锁着”的经典 bug。参数说明price用DECIMAL(10,2)而不是FLOAT因为金额计算不能有浮点误差images用逗号分隔的字符串存多图虽然不符合范式但在小程序场景下比单独建图片表更省事读取时split(,)即可。credit信用分是给后续“恶意下单”“放鸽子”行为留的惩罚接口初期可以不用但字段先留着。2.3 商品发布接口的最小实现后端用什么语言都行这里以 Node.js Express 为例因为和小程序前端同属 JS 生态调试成本低。核心是发布商品时校验必填项并写入goods表// POST /api/goods/publish const express require(express); const router express.Router(); const db require(../db); // 假设已封装 mysql2/promise 连接池 router.post(/publish, async (req, res) { const { openid, title, description, price, category, images, location } req.body; // 1. 参数校验标题、价格、分类必填 if (!title || !price || !category) { return res.json({ code: 400, msg: 标题、价格、分类不能为空 }); } if (Number(price) 0) { return res.json({ code: 400, msg: 价格必须大于 0 }); } try { // 2. 根据 openid 查用户没有就自动注册小程序静默登录常见做法 let [users] await db.query(SELECT id FROM user WHERE openid ?, [openid]); let sellerId; if (users.length 0) { const [result] await db.query( INSERT INTO user (openid, nickname) VALUES (?, ?), [openid, 用户 Date.now()] ); sellerId result.insertId; } else { sellerId users[0].id; } // 3. 写入商品status 默认 on_sale const [insertResult] await db.query( INSERT INTO goods (seller_id, title, description, price, category, images, location) VALUES (?, ?, ?, ?, ?, ?, ?), [sellerId, title, description || , price, category, images || , location || ] ); res.json({ code: 0, msg: 发布成功, data: { goodsId: insertResult.insertId } }); } catch (err) { console.error(发布商品失败, err); res.json({ code: 500, msg: 服务器内部错误 }); } }); module.exports router;逻辑说明这里没有用微信官方的code2session换 openid 的完整流程而是假设前端已经拿到 openid 传过来。真实项目里openid 必须在后端通过wx.login的 code 换取不能信任前端传值。但作为毕业设计演示先跑通业务逻辑更重要安全加固可以放在文档的“后续优化”里写。参数说明category建议用固定枚举值前端用 picker 选择避免用户乱填导致分类筛选失效。images传的是图片 URL 拼接后的字符串图片上传接口可以用multer存本地也可以接云存储毕业设计里存本地uploads/目录最省事。3. 小程序端列表、详情、下单三个页面的关键逻辑3.1 商品列表的分页与筛选怎么做才不卡校园二手平台的列表页有两个特点数据量不大几千条顶天但筛选需求强按分类、按价格、按关键词。如果用LIMIT offset, size做分页翻到后面会越来越慢因为 MySQL 要扫描前 offset 条。常见做法是用“游标分页”记住上一页最后一条的id下一页查WHERE id lastId ORDER BY id DESC LIMIT 20。这样每页查询都是走主键索引速度稳定。小程序端用onReachBottom触发加载更多核心代码// pages/goods/list.js Page({ data: { goodsList: [], lastId: 0, category: , keyword: , hasMore: true }, onLoad() { this.loadGoods(true); }, // 下拉刷新 onPullDownRefresh() { this.setData({ goodsList: [], lastId: 0, hasMore: true }); this.loadGoods(true).then(() wx.stopPullDownRefresh()); }, // 触底加载 onReachBottom() { if (this.data.hasMore) this.loadGoods(false); }, async loadGoods(reset) { const { lastId, category, keyword } this.data; const res await wx.request({ url: https://your-domain.com/api/goods/list, data: { lastId: reset ? 0 : lastId, category, keyword, size: 20 } }); if (res.data.code 0) { const list res.data.data.list; this.setData({ goodsList: reset ? list : this.data.goodsList.concat(list), lastId: list.length 0 ? list[list.length - 1].id : lastId, hasMore: list.length 20 }); } } });逻辑说明lastId初始为 0后端查询时如果lastId为 0 就查最新 20 条否则查id lastId的 20 条。hasMore的判断依据是“本页返回条数是否等于 pageSize”小于 20 说明到底了。这个逻辑比offset分页多一个字段但性能稳定面试时也能讲出区别。参数说明size固定 20不要设太大小程序一次渲染太多节点会卡。category和keyword作为筛选条件后端要建联合索引(status, category, id)来加速。3.2 下单接口如何防止“一物二卖”这是校园二手平台最核心的并发问题两个买家同时点“立即购买”如果代码写得随意会出现两个订单都创建成功但商品只有一个。解决思路是在事务里用SELECT ... FOR UPDATE锁住商品行检查status是否为on_sale是则更新为locked并创建订单否则返回“已被抢先下单”。// POST /api/order/create router.post(/create, async (req, res) { const { openid, goodsId, remark } req.body; const conn await db.getConnection(); // 从连接池取连接 try { await conn.beginTransaction(); // 1. 查买家 const [users] await conn.query(SELECT id FROM user WHERE openid ?, [openid]); if (users.length 0) throw new Error(用户不存在); const buyerId users[0].id; // 2. 锁商品行检查状态 const [goodsRows] await conn.query( SELECT id, seller_id, price, status FROM goods WHERE id ? FOR UPDATE, [goodsId] ); if (goodsRows.length 0) throw new Error(商品不存在); const goods goodsRows[0]; if (goods.status ! on_sale) throw new Error(商品已被下单或已售出); if (goods.seller_id buyerId) throw new Error(不能购买自己发布的商品); // 3. 更新商品状态为 locked await conn.query(UPDATE goods SET status ? WHERE id ?, [locked, goodsId]); // 4. 创建订单 const [orderResult] await conn.query( INSERT INTO order (goods_id, buyer_id, seller_id, amount, remark) VALUES (?, ?, ?, ?, ?), [goodsId, buyerId, goods.seller_id, goods.price, remark || ] ); await conn.commit(); res.json({ code: 0, msg: 下单成功, data: { orderId: orderResult.insertId } }); } catch (err) { await conn.rollback(); res.json({ code: 400, msg: err.message }); } finally { conn.release(); } });逻辑说明FOR UPDATE是行级锁只有事务提交后才释放。第二个请求进来时会被阻塞等第一个事务提交后它读到的status已经是locked于是抛出“已被下单”。这就是防一物二卖的标准做法。注意conn.release()必须放在finally里否则连接池会被耗尽。参数说明remark是买家留言比如“明天下午三点在图书馆门口交易”。这个字段虽然简单但在校园场景里非常实用能减少很多沟通成本。3.3 订单状态流转与“确认收货”的边界订单创建后状态是created。接下来有两种走向卖家确认交易confirmed然后买家确认收货finished或者任意一方取消cancelled。这里有个容易忽略的边界如果订单取消商品状态必须回滚到on_sale否则商品就永远锁死了。// POST /api/order/cancel router.post(/cancel, async (req, res) { const { orderId, openid } req.body; const conn await db.getConnection(); try { await conn.beginTransaction(); const [orders] await conn.query( SELECT id, goods_id, buyer_id, seller_id, status FROM order WHERE id ? FOR UPDATE, [orderId] ); if (orders.length 0) throw new Error(订单不存在); const order orders[0]; // 只有 created 或 confirmed 状态可以取消 if (![created, confirmed].includes(order.status)) { throw new Error(当前状态不可取消); } // 校验操作人是否是买卖双方之一 const [users] await conn.query(SELECT id FROM user WHERE openid ?, [openid]); const userId users[0].id; if (userId ! order.buyer_id userId ! order.seller_id) { throw new Error(无权操作此订单); } // 更新订单状态 await conn.query(UPDATE order SET status ? WHERE id ?, [cancelled, orderId]); // 商品回滚到在售 await conn.query(UPDATE goods SET status ? WHERE id ?, [on_sale, order.goods_id]); await conn.commit(); res.json({ code: 0, msg: 已取消 }); } catch (err) { await conn.rollback(); res.json({ code: 400, msg: err.message }); } finally { conn.release(); } });逻辑说明取消订单和回滚商品状态必须在同一个事务里要么都成功要么都失败。如果先更新订单再更新商品中间断电了就会出现“订单取消了但商品还锁着”的数据不一致。事务是这里的后悔药。参数说明orderId和openid是必传。openid用来校验操作权限防止 A 用户取消 B 用户的订单。这个校验在毕业设计里经常被忽略但它是安全性的基本要求。4. 避坑与排查那些让答辩现场尴尬的细节4.1 图片上传后在小程序里显示 404现象后端用multer把图片存到uploads/目录数据库里也存了路径但小程序image标签加载不出来控制台报 404。原因Express 默认不会把uploads/目录暴露成静态资源需要显式配置app.use(/uploads, express.static(uploads))。另外小程序请求的域名必须是 HTTPS且要在微信公众平台配置合法域名本地开发时可以在开发者工具里勾选“不校验合法域名”。解决后端加静态目录配置图片 URL 用完整域名拼接不要只存相对路径开发阶段用开发者工具的“不校验”选项绕过域名检查上线前再配好 HTTPS 和域名白名单。4.2 下单时提示“商品已被下单”但列表里还显示在售现象商品被下单后goods.status变成locked但列表页仍然显示这件商品点进去才提示已被下单。原因列表查询没有过滤status把locked和sold的商品也查出来了。或者前端缓存了旧数据没有在onShow里刷新。解决列表查询固定加WHERE status on_sale详情页也要判断状态非on_sale时把“立即购买”按钮置灰。前端在onShow里重新拉取列表避免用户从详情页返回后看到过期数据。4.3 微信登录 code 换 openid 报 40029现象调用code2session接口时返回errcode: 40029提示 code 无效。原因wx.login拿到的 code 只能使用一次且有效期 5 分钟。如果前端重复用同一个 code 请求或者后端在换 openid 之前先做了其他耗时操作导致 code 过期就会报这个错。解决每次登录都重新调wx.login拿新 code后端收到 code 后立即请求微信接口不要插入其他逻辑把 openid 存到 session 或返回给前端缓存后续请求用 openid 而不是重复换。4.4 订单列表按时间倒序但分页时出现重复数据现象第一页和第二页有重复的订单或者漏掉某条。原因ORDER BY create_time DESC时如果两条订单的create_time完全相同同一秒内创建MySQL 的排序是不稳定的分页时可能把同一条数据分到两页。解决排序条件加上唯一字段比如ORDER BY create_time DESC, id DESC。这样即使时间相同也能按 id 稳定排序。这个坑在数据量小的时候不容易发现一旦并发上来就会暴露。4.5 毕业设计文档里“数据库设计”章节写得太薄现象代码跑通了但文档里数据库设计只有几张表的截图没有字段说明、没有 ER 图、没有索引设计理由答辩时被问“为什么用 ENUM 不用 TINYINT”答不上来。原因把精力全放在写代码上忽略了文档也是评分项。解决每张表列一个字段表格写清楚字段名、类型、是否必填、说明ER 图用 draw.io 画标注一对多关系索引设计单独写一段解释idx_status_category是为了加速“按分类筛选在售商品”这个高频查询。这些内容不需要额外开发但能显著提升文档质量。5. 让这套源码真正“可交付”的两个进阶动作5.1 用定时任务清理“僵尸订单”校园二手交易有个现实问题买家下单后双方约好线下交易但一方放鸽子订单一直挂在created状态商品也一直锁着。如果不处理在售商品会越来越少。我一般会加一个定时任务每天凌晨跑一次把创建超过 48 小时且状态仍为created的订单自动取消并回滚商品状态。// 每天凌晨 3 点执行 const cron require(node-cron); cron.schedule(0 3 * * *, async () { const conn await db.getConnection(); try { await conn.beginTransaction(); // 查出超时未确认的订单 const [orders] await conn.query( SELECT id, goods_id FROM order WHERE status created AND create_time DATE_SUB(NOW(), INTERVAL 48 HOUR) FOR UPDATE ); for (const order of orders) { await conn.query(UPDATE order SET status ? WHERE id ?, [cancelled, order.id]); await conn.query(UPDATE goods SET status ? WHERE id ?, [on_sale, order.goods_id]); } await conn.commit(); console.log(清理了 ${orders.length} 条超时订单); } catch (err) { await conn.rollback(); console.error(清理超时订单失败, err); } finally { conn.release(); } });逻辑说明DATE_SUB(NOW(), INTERVAL 48 HOUR)算出 48 小时前的时间点只处理创建时间早于这个点的订单。FOR UPDATE防止清理任务和用户手动取消同时操作同一条订单。这个任务不需要很精确每天跑一次就够关键是让商品能回到在售池。参数说明48 小时可以根据实际情况调整比如改成 24 小时更激进或者 72 小时更宽松。cron表达式0 3 * * *表示每天 3:00 执行服务器时区要设对否则会在错误的时间跑。5.2 用“信用分”约束放鸽子行为信用分字段user.credit初始 100每次订单被取消时如果是买家主动取消且商品已锁定超过 12 小时扣 5 分如果是卖家确认后又不交易扣 10 分。信用分低于 60 的用户发布商品时需要人工审核或者直接限制发布。这个机制不需要很复杂但能让平台有自我净化的能力答辩时也是一个亮点。// 在取消订单接口里追加信用分逻辑 if (userId order.buyer_id order.status created) { // 买家取消扣 5 分 await conn.query(UPDATE user SET credit GREATEST(credit - 5, 0) WHERE id ?, [userId]); } else if (userId order.seller_id order.status confirmed) { // 卖家确认后取消扣 10 分 await conn.query(UPDATE user SET credit GREATEST(credit - 10, 0) WHERE id ?, [userId]); }逻辑说明GREATEST(credit - 5, 0)保证信用分不会变成负数。扣分规则写死在代码里虽然不够灵活但毕业设计阶段够用。如果想做得更细可以单独建一张credit_log表记录每次扣分原因方便追溯。参数说明扣分数值 5 和 10 是拍脑袋定的你可以根据实际测试调整。关键是让规则可解释买家取消扣得少卖家确认后取消扣得多因为后者对买家伤害更大。5.3 一个我踩过的坑不要用openid当用户主键早期图省事直接把openid设成user表的主键结果后面想加“学号绑定”“校园卡认证”时发现openid是字符串关联查询效率低而且换微信账号后数据迁移很麻烦。后来改成自增id做主键openid只做唯一索引所有关联都用id。这个改动在项目初期成本很低但拖到后期就是伤筋动骨。如果你正在建表现在就改过来。这套源码的价值不在于代码有多复杂而在于它把校园二手交易的真实业务逻辑——状态流转、并发控制、超时清理、信用约束——都落到了可运行的实现上。你可以直接拿它当毕设基础改改 UI、加个聊天功能、接个云存储就能变成自己的作品。希望帮到你。本文还有配套的精品资源点击获取