简介:基于微信小程序的校园二手交易平台源码包,后台采用微信云开发,实现租赁与买断两种交易模式,适合正在学习小程序开发、准备课程设计或毕业设计的同学参考。整套资源共9个文件,其中6个json为项目配置,1个zip为源码主体,1个txt为说明文档,1张png为预览示意图,压缩包仅2.48MB,结构清晰便于检索。已有2608人下载学习。开发者通过B站视频(BV13L411z7WQ)演示了配置过程,遇到环境配置问题可对照视频操作;但由于云开发实例有效期截至2023-06-26,此后数据库内图片可能变为空链接,需按照视频自行上传图片到库中。源码功能定位于学习用途,尚未达到商用级别,适合用于理解小程序前后端交互、云数据库操作和二手交易流程设计,可作为二次开发的基础。
1. 校园二手交易小程序源码:拆完这套工程我先把这些坑写出来
临近毕业季,宿舍楼下全是带不走的显示器、专业书和九成新的小电锅,这套基于微信小程序的校园二手交易平台源码,就是冲着这个场景做的。它把微信登录、手机号授权、商品发布、信息流分页、订单状态流转这些主链路都实现了,前端是原生小程序,后端配套接口定义和数据库脚本,工程里还带了说明文档和演示图。拿到手能直接跑,也能拆开改。
它适合三类人群:拿它做课程设计的学生,需要一份能讲清楚每个文件责任的参考工程;准备接校园类外包的个人开发者,需要一个不用从零写脚手架的底子;以及想练完整前后端链路的小程序新手。不承诺上线就躺着赚钱,微信审核、个人主体限制、类目选择照样卡人,这些我放到最后一章单独说。
2. 工程结构与技术选型:原生小程序加自建接口,骨架先看清
这个资源用的是原生小程序开发,不是 uniapp 打包出来的产物。很多人搜过“uniapp 微信小程序打包”,那是另一条路线:H5 技术栈编译到小程序,好处是一套代码多端复用,坏处是多了编译层,遇到平台差异要查转换规则。这套源码选原生,对一个课程设计级别的校园交易项目来说是对的——你能直接看懂每个页面的生命周期,断点调试时定位到的是真实代码而不是编译产物。
拆工程的第一步不是打开 App.js,而是先把目录结构读明白。一个合格的小程序工程,页面、组件、工具函数、接口层是分开的,如果看到所有逻辑都堆在 page 里,那才是需要警惕的。
2.1 目录结构拆解:每个文件夹在工程里干什么
常见的目录组织方式是这样的,这套源码也大致遵循这个约定:
| 目录 / 文件 | 职责 | 说明 |
|---|---|---|
| pages/index | 首页信息流 | 商品列表、搜索入口、分类筛选 |
| pages/detail | 商品详情页 | 图片轮播、卖家信息、状态操作 |
| pages/publish | 发布页 | 图片上传、表单、价格设定 |
| pages/mine | 个人中心 | 我的发布、我的订单、设置 |
| components | 自定义组件 | 商品卡片、空状态、加载更多指示器 |
| utils | 工具函数 | request 封装、登录态管理、格式化 |
| api | 接口定义 | 按模块拆分的请求方法 |
页面层和组件层分开,是为了让商品卡片这类高频复用的 UI 不重复写。我的习惯是:先看 utils 里的 request 封装,再看 api 目录,最后才看页面。因为页面只是调用方,请求的 baseUrl、token 注入、错误处理都集中在封装里,这三层读懂了,整个工程的通信方式就清楚了。
工具函数里通常会有一个 config 文件,负责集中管理接口地址和环境切换:
// utils/config.js module.exports = { // 开发环境:模拟器可以直连 127.0.0.1,真机预览必须改局域网 IP baseUrl: 'http://127.0.0.1:8080/api', imageDomain: 'http://127.0.0.1:8080/files/', // 分页默认大小,列表页和搜索页共用 pageSize: 10 }这里最关键的参数是 baseUrl。模拟器里 127.0.0.1 指向你电脑本机,后端跑在电脑上就能通;真机预览时这个地址指向手机自己,必须改成电脑在局域网里的 IP,比如 http://192.168.1.5:8080/api。这一条后面避坑章节还会再提,因为它是新手翻车第一名。
2.2 数据表与接口设计:先看懂状态机再改功能
交易平台的核心不在页面,在状态。这套资源的数据表设计里,商品和订单都带着明确的状态字段。
商品表 goods 常见字段:id、uid(卖家)、title、description、category、price、images、status、created_at。price 一般用整数分存储,比如 15.5 元存 1550,避免浮点运算丢精度,这是电商类项目的通用做法。
status 字段决定商品的生命周期:
| status 值 | 含义 | 触发条件 |
|---|---|---|
| 0 | 在售 | 发布成功或重新上架 |
| 1 | 已售 | 买家下单并确认交易 |
| 2 | 下架 | 卖家手动下架 |
| 3 | 被删除 | 软删除,不进列表 |
订单表 order 同理,状态机是 0 待确认、1 待交易、2 已完成、3 已取消。很多人改功能时把状态当成布尔值用,is_sold 一个字段打天下,短期能跑,后面要加“下架再上架”“订单取消”就非常痛苦。
接口层按资源维度拆,一般包括:
- POST /api/login 微信登录换 token
- POST /api/goods 发布商品
- GET /api/goods 商品列表(分页)
- GET /api/goods/:id 商品详情
- PUT /api/goods/:id/status 改状态
- POST /api/order 创建订单
目录里如果带了后端工程,直接按这张表对;如果只带了接口 mock 数据,也能据此把前后端联调跑通。
3. 微信登录与手机号授权:从 wx.login 到 session 维护的一次跑通
校园二手交易和普通内容类小程序最大的区别是:买卖双方必须能联系。所以这套源码在登录链路里做了两层——静默登录 wx.login 换 code,以及用户主动点击后获取手机号。这两个机制经常被搞混,尤其“微信小程序登录获取手机号”是搜索热度很高的词,就是因为很多人把 wx.login 的 code 和手机号回调的 code 当成同一个东西。
先明确一个底层事实:wx.login 拿到的 code 只能用来换用户的 openid,它证明“这个微信用户是谁”;手机号按钮回调里的 code 只能换手机号,它证明“这个手机号属于谁”。两者不能互相替代。
3.1 静默登录:wx.login 换 code 的完整链路
登录页面的核心逻辑是这样:
// pages/login/login.js Page({ data: { // 基础库 2.10.4 之后才支持 wx.getUserProfile canIUseProfile: typeof wx.getUserProfile === 'function' }, handleLogin() { wx.login({ success: (res) => { // res.code 有 5 分钟有效期,且只能使用一次 const code = res.code wx.request({ url: `${app.globalData.baseUrl}/login`, method: 'POST', // 服务端拿 code 换 openid,签发自定义 token data: { code } }) } }) } })这段代码的链路是:wx.login 生成一次性 code → 小程序把 code 发给自己的后端 → 后端拿 code 调微信的 jscode2session 接口 → 微信返回 openid 和 session_key → 后端用自己的规则签发 token → 小程序把 token 存进 Storage,之后所有请求带上它。
参数上要记住两点。第一,code 有效期 5 分钟,且是一次性的,同一个 code 调两次 jscode2session 会报错;第二,openid 是用户在小程序内的唯一标识,但小程序端永远拿不到 session_key,这是安全边界,后端不要把 session_key 下发给前端。
token 拿到后要统一塞进请求头。常见做法是在 utils/request.js 里做拦截:
// utils/request.js const request = (options) => { const token = wx.getStorageSync('token') return new Promise((resolve, reject) => { wx.request({ url: `${baseUrl}${options.url}`, method: options.method || 'GET', data: options.data || {}, header: { // 后端靠这个字段识别登录用户,命名要前后端约定一致 'Authorization': `Bearer ${token}` }, success: (res) => resolve(res.data), fail: (err) => reject(err) }) }) }3.2 获取手机号按钮:回调里是 code,不是手机号
发布商品或下单时,如果后端要求绑定手机号,页面用的是 open-type 为 getPhoneNumber 的 button:
<!-- 页面里必须是 button,不能是普通 view --> <button open-type="getPhoneNumber" bindgetphonenumber="handleGetPhone"> 获取手机号 </button>// pages/mine/mine.js async handleGetPhone(e) { // e.detail.code 是动态令牌,不是手机号明文 const phoneCode = e.detail.code if (!phoneCode) { // 用户点了拒绝,这里要给提示而不是静默跳过 wx.showToast({ title: '需要手机号才能发布商品', icon: 'none' }) return } const res = await request({ url: '/user/phone', method: 'POST', data: { code: phoneCode } }) // 服务端解密后返回真实手机号 console.log(res.phoneNumber) }基础库 2.21.2 之后,getPhoneNumber 回调里拿到的不是手机号密文,而是一个 code。这个 code 要发给后端,后端拿着它配合 access_token 调微信接口换取手机号。也就是说,前端永远看不到手机号原始数据,只能在需要展示时让后端返回脱敏结果。
这里有个新手经常踩的坑:把 button 换成 view 然后手动调 wx.getPhoneNumber,这是不存在的 API。手机号能力必须通过 button 的开放能力触发,用户在按钮上的点击行为本身包含授权语义,微信不会给纯代码调用放开这个权限。
4. 商品发布与列表加载:图片上传、分页与导航栏适配一次说清
交易平台的高频操作就两个:发布和浏览。这一章把这两条链路拆开讲,每条都会给可复制的代码段和参数含义。发布页的难点在图片上传,列表页的难点在分页加载和顶部导航栏适配。
先说图片。小程序里选择图片用 wx.chooseMedia,不要再用已经废弃的 wx.chooseImage。发布时的多图上传,如果一张一张串行上传,用户会明显感觉到慢;正确做法是并行上传后用 Promise.all 等全部完成。
4.1 商品发布流程:图片上传、表单校验与价格单位
// pages/publish/publish.js chooseAndUploadImages() { wx.chooseMedia({ count: 6, // 最多选 6 张,和表单校验保持一致 mediaType: ['image'], // 只允许图片,不允许视频 sizeType: ['compressed'], // 只传压缩图,原图体积太大 success: (res) => { const tasks = res.tempFiles.map((file) => { return new Promise((resolve, reject) => { wx.uploadFile({ url: `${baseUrl}/upload`, filePath: file.tempFilePath, name: 'file', // 和后端接收字段一致 success: (upRes) => { // 后端返回的图片地址,存起来用于提交表单 const data = JSON.parse(upRes.data) resolve(data.url) }, fail: reject }) }) }) // 全部传完再更新 UI,避免用户看到一半的图 Promise.all(tasks).then((urls) => { this.setData({ images: this.data.images.concat(urls) }) }) } }) }参数上有三个注意点。count 必须和表单校验里的最大张数一致,否则用户选 9 张、前端只显示 6 张,体验割裂;sizeType 用 compressed 可以显著减少流量消耗,校园网场景下尤其重要;Promise.all 的作用是并发,而不是串行,如果后端吞吐有限,把并发数量限制到 3 也是常见策略。
价格输入要单独处理。用户输入的是元,提交时要转成整数分:
// 表单提交前统一转换 const priceYuan = this.data.price const priceFen = Math.round(parseFloat(priceYuan) * 100) if (!priceFen || priceFen <= 0) { wx.showToast({ title: '请填写正确价格', icon: 'none' }) return }另外成色选项这种单选场景,小程序里用 radio-group 而不是多个 checkbox,语义上更符合“只能选一个”的预期。
4.2 信息流分页与顶部导航栏高度适配
列表页用的是页面级滚动,也就是 page 本身就是 scroll-view。这时候分页靠的是 onReachBottom 生命周期,而不是 scroll-view 的 bindscrolltolower。两者的区别是:onReachBottom 只在页面滚动到接近底部时触发一次,而 scroll-view 的滚动事件会在滚动过程中高频触发,容易造成重复请求。
// pages/index/index.js Page({ data: { list: [], pageNo: 1, pageSize: 10, finished: false, // 标记是否已全部加载 loading: false // 防止重复请求 }, onReachBottom() { // 一屏数据没满时这个事件不触发,要主动补一次 this.loadGoods() }, async loadGoods() { if (this.data.loading || this.data.finished) return this.setData({ loading: true }) const { pageNo, pageSize } = this.data const res = await request({ url: '/goods', data: { pageNo, pageSize, status: 0 } }) const list = res.list this.setData({ // 追加而不是覆盖,这是分页的核心逻辑 list: this.data.list.concat(list), pageNo: pageNo + 1, finished: list.length < pageSize, // 返回不足一页说明到底了 loading: false }) } })这里的 finished 和 loading 是两个必须同时存在的开关。只做 finished、不做 loading,快速滚动时会发出大量重复请求;只做 loading、不做 finished,会把最后一页反复拉。setData 的频率也要控制,分页场景里一次 setData 带一整批数据,不要每条数据单独 setData,那样会触发多次渲染。
再看“微信小程序顶部导航栏高度”这个热度很高的适配问题。默认导航栏在 iPhone X 之后会出现顶部内容被刘海遮挡的问题,自定义导航栏是常见解法。取高度不能靠死值,要用系统接口:
// app.js 里初始化时算好,全局共享 const systemInfo = wx.getSystemInfoSync() const menuButton = wx.getMenuButtonBoundingClientRect() const navBarHeight = (menuButton.top - systemInfo.statusBarHeight) * 2 + menuButton.height // 返回给页面使用的结构 { statusBarHeight: systemInfo.statusBarHeight, // 状态栏高度 navBarHeight, // 自定义导航栏总高度 menuButton // 胶囊位置,右侧按钮要用 }原理是:胶囊按钮垂直居中于导航栏,所以导航栏高度等于胶囊高度加上胶囊顶部到状态栏底部的距离的两倍。iPhone 和安卓的值不同,同一台手机不同基础库版本也可能不同,所以每次启动动态计算,而不是存死值。自定义导航栏模式下,页面里的固定头部都要用这个高度做 padding-top。
5. 避坑记录:这套源码最容易翻车的五个地方
资源是能跑的,但能跑不代表不会翻车。我把复现过程中最容易出问题的位置集中写在这里,每一条都是具体现象加原因加解决方式,按从高频到低频排序。
5.1 现象到原因到解决:五条真实踩坑记录
第一条:模拟器一切正常,真机预览请求全部失败
现象:开发者工具里列表能加载、图片能显示,用手机扫码预览后页面空白,控制台报 network error。
原因:前端代码里 baseUrl 配的是 127.0.0.1,模拟器里指向电脑本机,真机上指向手机自己。手机和小程序后端根本不在同一个网络空间。
解决:把 baseUrl 改成电脑的局域网 IP,手机和电脑连同一个 WiFi。后端服务监听 0.0.0.0 而不是默认的 localhost。如果后端跑在云服务器上,直接配服务器公网 IP 或域名。这个排查顺序可以固定成肌肉记忆:先 ping 通、再测端口、最后看后端日志。
第二条:wx.login 返回的 code 第二次失效
现象:用户登录成功后,在某个页面主动调了一次 wx.login 拿新 code,然后之前的登录态丢了,后端报 40029 invalid code。
原因:wx.login 的 code 有效期 5 分钟而且只能用一次。有的开发者把 code 存起来当 token 用,或者用两个请求同时携带同一个 code,第二个请求必然失败。40029 是微信返回的官方错误码,表示 code 无效。
解决:code 只用于登录接口一次性换取 token,之后所有请求带 token。小程序端不要主动重复调用 wx.login,除非后端明确说要刷新会话。维护登录态的标准做法是:启动时先检查 Storage 里的 token 是否存在且有效,无效再走 wx.login。
第三条:图片上传成功,但页面死活显示不出来
现象:上传接口返回了 200,图片 URL 也存进数据库了,但商品详情页的 image 组件一片空白。
原因:两层问题。第一,wx.uploadFile 的 filePath 是临时文件路径,直接存这个路径到数据库,小程序冷启动后临时文件已经被清掉;第二,后端保存文件后返回的 URL 和 imageDomain 拼接错误,比如 imageDomain 结尾没有斜杠,拼出来变成 http://xxx.comfiles/1.jpg。
解决:后端拿到文件后要移动到持久化目录,返回的是http://域名/files/1.jpg 这种完整路径。前端展示时不要直接用 tempFilePath。检查拼接时先看开发者工具 Network 面板里图片请求的完整 URL,一眼就能看出是路径问题还是文件问题。
第四条:onReachBottom 触发后列表卡顿、数据重复
现象:快速下划列表,页面明显掉帧,滑到底部时同一页数据出现好几条。
原因:loadGoods 没有 loading 锁,onReachBottom 在底部附近触发时,一次滚动可能连续进入多次;同时 setData 传入了整个新旧拼接后的数组,渲染层要做大量 diff。
解决:加上文写法里的 loading 开关,进入请求时置 true,完成后再置 false。数据量大时用 setData 只更新新增部分:
// 分页追加时只传增量 this.setData({ [`list[${this.data.list.length}]`]: newItem })这种方式在小程序里比整体覆盖数组性能更好,但增量式更新逻辑复杂一些,列表不超过 50 条时直接用 concat 整体覆盖即可。
第五条:手机号按钮点击后 e.detail.code 为空
现象:用户点击了“获取手机号”按钮,回调触发,但 e.detail.code 是 undefined,后端也没有手机号数据。
原因:按钮没有声明 open-type="getPhoneNumber",或者用户点击的是拒绝。open-type 不是 bindgetphonenumber 的附属品,是独立的属性,写漏了整个开放能力就不会生效。
解决:检查 button 标签是否同时具备 open-type 和 bindgetphonenumber 两个属性。code 为空时要在前端给出明确提示,而不是静默失败。另外注意,这个能力要求小程序已认证,个人主体的小程序无法调用真实手机号接口。
5.2 报错信息怎么读:从 errMsg 到代码行的定位顺序
小程序报错经常是一长串 json,新手容易被吓住。我的定位顺序是固定的:
第一层看 errMsg,它已经告诉你失败方向。比如 request:fail timeout 是网络超时,request:fail url not in domain list 是域名没配。第二层看 HTTP 状态码和响应体,后端错误一般会带自定义 code 和 message。第三层才进代码查。
后端返回的 code 需要前后端约定一套语义。常见约定是 0 表示成功,非 0 按模块分段,比如 10001 开头是参数错误、20001 开头是登录态问题。源码里如果没有,建议自己补一层统一响应包装,否则每个接口各自返回不同格式,前端处理逻辑会写成一坨分支。
排查时还有个小技巧:在 utils/request.js 的成功回调里打一行完整日志,包含 url、method、data、response。页面上的问题能直接据此定位,不用反复在页面里加 log。
6. 从模拟器到真机再到上线:源码该这么验证一遍
6.1 体验版与真机调试:发给别人试用时注意什么
开发者工具里点“预览”会生成二维码,用手机微信扫码就能在真机环境跑真实代码。这个二维码一段时间后会失效,过期了重新点一次预览就行。要收集几天试用反馈,标准做法不是反复发预览码,而是上传体验版:开发者工具点“上传”,然后在微信公众平台后台把测试者微信号加进体验成员列表,体验成员扫体验版二维码就能独立使用。预览版和体验版的区别在于,预览版依赖开发机实时编译,电脑关了就断;体验版是上传到微信服务器的完整包,不依赖你的电脑。
预览码适合快速自测,体验版适合发给 10 个以内的朋友收集反馈。体验版和正式版跑的是同一份代码,但域名校验严格得多——未配置的合法域名在真机环境会被拦,预览时还能勾选“不校验合法域名”绕过,体验版没有这个开关。
6.2 隐私协议与审核清单:最后还是绕不过去
上线审核前有两件事必须处理:隐私保护指引和类目选择。微信公众平台后台要配置用户隐私保护指引,声明收集手机号、头像等信息的用途,不配置的话涉及用户信息的接口会被拦截。类目方面,二手交易类目有额外要求,提交审核时要以当时生效的平台规则为准,个人主体能覆盖的类目比企业主体窄不少,这是选型阶段就该确认的。
我对这类源码的验证习惯是固定的:拿到工程后先开一个干净的小程序账号,清掉 Storage 和授权缓存,从 wx.login 开始完整跑一遍“登录 → 发布 → 浏览 → 下单”链路,中间故意断一次网再恢复,观察登录态和请求队列是否自愈。从那以后我每次复现交易类小程序源码都强制走一遍这条链,因为大多数隐蔽 bug 都藏在断网重连和重复点击里。希望帮到你。
本文还有配套的精品资源,点击获取