
简介校园二手交易小程序源程序面向高校学生与微信小程序开发者围绕校园闲置物品发布、检索、交易与管理等场景提供一套可参考、可运行的完整项目代码。压缩包共1549个文件、约989KB以js、ts、wxss、json、wxml、wxs等小程序常见文件为主配合少量png图片、md说明与bat脚本覆盖页面结构、样式、逻辑、配置及云函数等模块。已有489人学习适合课程设计、毕业设计或小程序入门实战对照。读者可从中了解用户登录、商品发布与筛选、购物车订单、支付回调、评价反馈及后台管理等功能的组织方式也能借鉴数据库字段划分、接口调用和页面组件拆分思路文件数量与类型较完整便于按目录定位前端页面、业务逻辑和配置脚本快速搭建自己的校园交易类小程序原型。1. 校园二手交易小程序一个毕业季场景引出的完整源程序每年六月宿舍楼下都会摆出一排纸箱里面是带不走的台灯、考研资料和山地车——这是校园二手交易小程序最真实的需求现场。把「群里刷屏出一条」变成「列表里翻到一件」中间要解决的是发布、检索、详情、联系这四件事缺一件用户就退回微信群。这份源程序就是围绕这四件事写的一套完整代码首页商品流带分类和关键词搜索发布页支持多图上传和价格填写详情页能看大图、能收藏、能留言个人中心管我发布的、我收藏的和下架的。技术上是微信原生小程序加云开发不用单独买服务器、配域名、走备案流程导入开发者工具、换一个云环境 ID 就能编译运行如果你打算接自己的后端前端的请求层也是独立封装的替换掉调用入口即可。适合想拿完整项目练手的小程序初学者、需要一个校园类课程设计骨架的学生以及想把通用二手模板改成自己学校专属版本的开发者。2. 技术选型与目录结构原生小程序 云开发这套组合怎么落到文件上先讲清楚为什么是这套组合再拆目录最后讲数据模型。三件事讲完你才知道后面每一行代码为什么放在那个位置而不是照着文件树盲抄。2.1 为什么是原生框架加云开发而不是上框架加自建后端校园二手这类应用的流量特征很极端开学季和毕业季是两个尖峰中间几个月日活低得可怜。这种曲线养一台常驻服务器非常不划算而云开发按调用量和存储计费校内几千人的规模基本落在免费额度或很低的档位内。原生框架的好处也很实际包体小、审核路径最短、社区里能搜到的资料最多不会因为第三方框架版本升级导致整个项目跑不起来。代价同样要说清楚不然二开时会翻车。云开发绑定微信生态将来想上 H5 或者 App数据层要整个重写云数据库的查询能力比 MySQL 弱不少做不了复杂的多表关联和聚合还有一个很多人第一次碰就懵的限制——小程序端直接查数据库一次最多返回 20 条云函数端最多 100 条。这条限制直接决定了商品列表必须走云函数分页而不是在前端db.collection(goods).get()一路拉到底。我在项目里就是按这个约束设计的列表、搜索、收藏全部经云函数中转。环境准备按这个顺序走跳过任何一步都会在后面报错用开发者工具导入项目AppID 必须填自己的不能用测试号否则云开发无法关联。在云开发控制台开通一个环境把环境 ID 复制下来。打开app.js把wx.cloud.init里的env换成你的环境 ID。在cloudfunctions目录下逐个右键云函数选「上传并部署云端安装依赖」一个都不能漏。在云开发控制台的数据库里手动建好集合并按后文 2.3 的表格设权限。编译先跑首页首页能出数据说明环境通了。工程配置文件里有三个字段值得单独看一眼换环境时最容易改错的就是它们{ miniprogramRoot: miniprogram/, cloudfunctionRoot: cloudfunctions/, appid: 替换成你自己的 AppID, projectname: campus-market, setting: { urlCheck: false, es6: true, enhance: true, minified: true } }miniprogramRoot告诉工具小程序代码在哪个子目录cloudfunctionRoot决定右键菜单里会不会出现「上传并部署」这一项——很多人导入项目后发现云函数右键没有部署选项就是这两个字段被改成绝对路径或者干脆丢了。urlCheck关掉是因为开发阶段可能存在未配置的域名请求云开发本身走的是内部通道不依赖这个开关但关掉能少一类报错干扰。appid留空的话云开发面板根本打不开。2.2 目录结构逐层拆解每个文件夹负责什么目录按「页面 / 组件 / 工具 / 云函数」四层切分不搞过深嵌套。二手交易的功能边界很清楚页面数量可控硬拆成模块化架构反而增加理解成本。campus-market/ ├── project.config.json ├── cloudfunctions/ │ ├── login/ 获取 openid、初始化用户档案 │ ├── getGoodsList/ 列表、分类、关键词搜索的统一入口 │ ├── publishGoods/ 发布校验与入库 │ ├── updateGoods/ 编辑、下架、标记售出 │ ├── sendMessage/ 留言与私信写入 │ └── getFavorites/ 我的收藏列表 └── miniprogram/ ├── app.js ├── app.json ├── app.wxss ├── pages/ │ ├── index/ 首页商品流 │ ├── search/ 搜索页 │ ├── detail/ 商品详情 │ ├── publish/ 发布 / 编辑 │ ├── message/ 留言会话 │ └── my/ 个人中心 ├── components/ │ ├── goods-card/ 列表卡片图片 标题 价格 成色 │ └── empty/ 空状态占位 ├── utils/ │ ├── format.js 相对时间、价格格式化 │ └── request.js 云函数调用的统一封装 └── images/components/goods-card单独抽出来是必要的首页、搜索页、我的收藏、我的发布四个地方用的是同一张卡片如果内联在 wxml 里改一次样式要动四个文件血泪经验。utils/request.js封装的是wx.cloud.callFunction统一处理 loading、错误码和超时页面里就不用每个请求都写一遍 try-catch。app.json里页面注册顺序有讲究排在第一位的pages/index/index就是启动页。tabBar 一般挂三个首页、发布、我的。发布页做成 tab 而不是普通页面是因为它是高频入口藏在二级页面里点击率会明显掉。2.3 数据模型goods、users、messages、favorites 四张集合怎么设计云开发是文档数据库没有表连接所以设计时要把「一次页面渲染需要哪些字段」直接放进同一个文档宁可冗余也别做关联查询。下面这张表是goods集合的字段清单是整套代码的核心。字段类型说明_idstring系统生成详情页跳转参数_openidstring发布者add时自动写入不要手写titlestring商品标题限制 30 字descstring描述限制 500 字pricenumber单位元支持两位小数originPricenumber原价用于展示划线价可不填categoryIdstring分类标识对应分类配置表imagesarray云存储 fileID 数组第一张作封面conditionstring成色全新 / 九成新 / 七成新statusstringon在架、locked已预订、off已下架或售出viewCountnumber浏览量用原子自增更新favoriteCountnumber收藏数createTimedate用db.serverDate()写入避免客户端时间不准users集合存_openid、昵称、头像、校区、信用分messages存from、to、goodsId、content、read、createTimefavorites存_openid、goodsId、createTime。四张集合的权限设置是新手最容易搞错的地方集合权限理由goods所有用户可读仅创建者可写列表和详情允许前端直读减少云函数调用users仅创建者可读写用户档案不允许被他人读取手机号等字段messages仅创建者可读写但收发双方不是同一个人所以实际全部走云函数favorites仅创建者可读写收藏只对自己可见注意messages这一行因为一条消息的发送者和接收者是两个不同的 openid「仅创建者可读写」会导致接收方读不到所以消息的读写必须全部放进云函数用服务端身份绕过权限限制并在函数里自行校验OPENID是不是会话参与方。这一点我在 4.2 会展开。3. 核心链路拆解一件商品从发布到被人收藏要经过哪些代码这一章跟一条完整链路走用户在首页刷到商品 → 点进详情 → 收藏 → 联系卖家另一条链路是卖家发布商品 → 上传图片 → 入库 → 出现在首页。两条链路合起来覆盖了项目里 80% 的业务代码。3.1 首页列表分页、下拉刷新与触底加载的三个状态位列表页最容易翻车的地方不是渲染而是状态管理。page、hasMore、loading这三个变量只要有一个没维护好就会出现重复数据、无限加载、下拉刷新后数据叠加这些现象。下面这段是首页的完整逻辑// pages/index/index.js const PAGE_SIZE 10; Page({ data: { list: [], page: 0, hasMore: true, loading: false, keyword: }, onLoad() { this.refresh(); }, // 下拉刷新页码归零列表清空后重拉 onPullDownRefresh() { this.refresh(() wx.stopPullDownRefresh()); }, // 触底加载已有请求在飞、或者没有更多数据时直接返回 onReachBottom() { if (this.data.loading || !this.data.hasMore) return; this.fetchList(); }, refresh(done) { this.setData({ list: [], page: 0, hasMore: true }); this.fetchList(done); }, fetchList(done) { if (this.data.loading) return; const { page } this.data; this.setData({ loading: true }); wx.cloud.callFunction({ name: getGoodsList, data: { page, pageSize: PAGE_SIZE, keyword: this.data.keyword } }).then(res { const { code, data } res.result; if (code ! 0) { wx.showToast({ title: data.msg || 加载失败, icon: none }); return; } this.setData({ // 用 concat 而不是 pushsetData 才能感知到数组变化 list: this.data.list.concat(data.list), page: page 1, // 返回条数不足一页说明已经到底 hasMore: data.list.length PAGE_SIZE }); }).catch(err { console.error([getGoodsList], err); wx.showToast({ title: 网络异常, icon: none }); }).finally(() { this.setData({ loading: false }); done done(); }); } });三个状态位的分工是这样的loading是防并发的闸门触底事件在快速滑动时会连续触发好几次没有这个标志位就会发出多个相同页码的请求page记录下一次要拉的页码成功之后才自增失败时保持不变下次重试还是同一页hasMore用「返回条数是否等于 pageSize」来判断这比让后端多返回一个总数要省一次查询代价是如果总数刚好是 pageSize 的整数倍会多请求一次空列表这个代价可以接受。对应的云函数把查询、过滤、分页、排序一次做完// cloudfunctions/getGoodsList/index.js const cloud require(wx-server-sdk); cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }); const db cloud.database(); const _ db.command; exports.main async (event) { const { page 0, pageSize 10, keyword , categoryId } event; // 基础条件只出在架商品已售出/已下架的不进列表 let condition { status: on }; if (categoryId) condition.categoryId categoryId; if (keyword) { // 标题和描述任命中即可正则加 i 忽略大小写 const reg db.RegExp({ regexp: keyword, options: i }); condition _.and([ condition, _.or([{ title: reg }, { desc: reg }]) ]); } try { const res await db.collection(goods) .where(condition) .orderBy(createTime, desc) .skip(page * pageSize) .limit(pageSize) // 云函数端单次上限 100这里取 10 .field({ // 列表不需要完整描述字段裁剪能明显减小包体 title: true, price: true, images: true, condition: true, createTime: true, status: true }) .get(); return { code: 0, data: { list: res.data } }; } catch (e) { console.error([getGoodsList], e); return { code: 5000, data: { msg: 查询失败 } }; } };几个参数值得说明skip(page * pageSize)是偏移分页数据量大到几万条时会变慢校园场景通常几千条以内够用如果将来要做无限滚动且数据量上去换成按createTime游标翻页更稳。field()只取列表需要的字段desc这种大文本不返回能显著降低传输体积。db.RegExp里的keyword直接拼进去有注入风险实际项目里我会先做一次keyword.replace(/[.*?^${}()|[\]\\]/g, \\$)转义避免用户输入.*把全表扫一遍。页面层对应的 wxml卡片用组件图片记得加lazy-load!-- pages/index/index.wxml -- view classgoods-grid goods-card wx:for{{list}} wx:key_id item{{item}} bindtapgoDetail >// pages/publish/publish.js Page({ data: { images: [], // 本地临时路径 title: , price: , desc: , categoryId: , submitting: false }, chooseImage() { const rest 9 - this.data.images.length; if (rest 0) return; wx.chooseMedia({ count: rest, mediaType: [image], sizeType: [compressed], // 压缩后再传能省一半流量 success: res { const paths res.tempFiles.map(f f.tempFilePath); this.setData({ images: this.data.images.concat(paths) }); } }); }, async submit() { const { title, price, desc, categoryId, images } this.data; if (this.data.submitting) return; if (!title.trim()) return wx.showToast({ title: 填个标题, icon: none }); if (!images.length) return wx.showToast({ title: 至少一张图, icon: none }); if (!(Number(price) 0)) return wx.showToast({ title: 价格不对, icon: none }); this.setData({ submitting: true }); wx.showLoading({ title: 发布中, mask: true }); const fileIDs []; try { // 第一步逐张上传云存储路径按日期分目录方便日后按天清理 for (let i 0; i images.length; i) { const ext (images[i].match(/\.(\w)$/) || [, jpg])[1]; const cloudPath goods/${Date.now()}-${i}.${ext}; const res await wx.cloud.uploadFile({ cloudPath, filePath: images[i] }); fileIDs.push(res.fileID); } // 第二步拿 fileID 调云函数入库 const r await wx.cloud.callFunction({ name: publishGoods, data: { title: title.trim(), price: Number(price), desc: desc.trim(), categoryId, images: fileIDs } }); if (r.result.code ! 0) throw new Error(r.result.msg); wx.hideLoading(); wx.showToast({ title: 发布成功 }); setTimeout(() wx.switchTab({ url: /pages/index/index }), 800); } catch (e) { // 入库失败就把已上传的图删掉别留孤儿文件 if (fileIDs.length) { await wx.cloud.deleteFile({ fileList: fileIDs }).catch(() {}); } wx.hideLoading(); wx.showToast({ title: e.message || 发布失败, icon: none }); } finally { this.setData({ submitting: false }); } } });submitting这个标志位不是可有可无的发布按钮在弱网下会被连点没有它就会重复入库用户个人中心里瞬间出现三条一样的商品。cloudPath用时间戳加序号命名比用原始文件名安全避免中文名和特殊字符在部分机型上上传失败。图片格式从路径里正则取扩展名取不到就默认jpg。3.3 云函数侧的增删改查把校验和权限放在服务端客户端的校验只是体验优化真正拦住脏数据的是云函数。publishGoods里必须重新校验一遍并且从业上下文里取OPENID绝不能相信前端传过来的用户标识// cloudfunctions/publishGoods/index.js const cloud require(wx-server-sdk); cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }); const db cloud.database(); exports.main async (event) { // 从调用上下文取前端伪造不了 const { OPENID } cloud.getWXContext(); const { title , price, desc , categoryId , images [] } event; if (!title.trim()) return { code: 1001, msg: 标题不能为空 }; if (title.length 30) return { code: 1002, msg: 标题超长 }; if (typeof price ! number || price 0 || price 99999) { return { code: 1003, msg: 价格不合法 }; } if (!Array.isArray(images) || images.length 0) { return { code: 1004, msg: 缺少图片 }; } const now db.serverDate(); const res await db.collection(goods).add({ data: { // _openid 由数据库自动写入这里不重复写 title: title.trim(), price, desc: desc.trim().slice(0, 500), categoryId, images, condition: event.condition || 九成新, status: on, viewCount: 0, favoriteCount: 0, createTime: now, updateTime: now } }); return { code: 0, data: { id: res._id } }; };注意add的时候不要手写_openid云开发在写入时会自动带上当前调用者的 openid手写反而可能被覆盖或者报字段冲突。createTime用db.serverDate()而不是new Date()是因为用户手机时间可能被手动改过用本地时间排序会出现「新发的商品排在三天前」这种玄学问题。下架和编辑走updateGoods// cloudfunctions/updateGoods/index.js exports.main async (event) { const { OPENID } cloud.getWXContext(); const { id, action, payload {} } event; const doc await db.collection(goods).doc(id).get().catch(() null); if (!doc || !doc.data) return { code: 4004, msg: 商品不存在 }; // 归属校验只能动自己的商品 if (doc.data._openid ! OPENID) return { code: 4003, msg: 无权限 }; if (action off) { // 下架是软删除改成 status真删会导致历史留言里的商品引用断链 await db.collection(goods).doc(id).update({ data: { status: off, updateTime: db.serverDate() } }); return { code: 0 }; } if (action edit) { const allow [title, price, desc, categoryId, images, condition]; const data {}; allow.forEach(k { if (payload[k] ! undefined) data[k] payload[k]; }); data.updateTime db.serverDate(); await db.collection(goods).doc(id).update({ data }); return { code: 0 }; } return { code: 4000, msg: 未知操作 }; };这里的allow白名单是关键直接update({ data: payload })会让用户有机会改掉_openid或者status把自己的商品「过户」给别人或者把已售出的商品重新上架。这种越权在测试阶段完全看不出来上线后才可能被人翻出来。3.4 详情页与收藏浏览量原子自增、收藏防重复详情页承担三个动作展示、计浏览、收藏与留言入口。浏览量的更新一定用_.inc(1)原子自增不能用「先读出来再写回去」并发时会丢计数// pages/detail/detail.js Page({ data: { goods: null, favorited: false, loading: true }, onLoad(options) { this.goodsId options.id; this.loadDetail(); }, async loadDetail() { const db wx.cloud.database(); const res await db.collection(goods).doc(this.goodsId).get(); this.setData({ goods: res.data, loading: false }); // 浏览数原子自增前端直写自己无权改的字段时会被权限拦下 // 所以这里走云函数或者把 goods 设为「所有人可写」由云函数兜底 wx.cloud.callFunction({ name: updateGoods, data: { id: this.goodsId, action: view } }); this.checkFavorite(); }, async checkFavorite() { const db wx.cloud.database(); const res await db.collection(favorites) .where({ goodsId: this.goodsId }) .count(); this.setData({ favorited: res.total 0 }); }, async toggleFavorite() { if (this.data.favorited) return; await wx.cloud.callFunction({ name: sendMessage, // 复用同一入口action 区分 data: { action: favorite, goodsId: this.goodsId } }); this.setData({ favorited: true }); wx.showToast({ title: 已收藏, icon: none }); } });收藏的防重复靠云函数里先查后写favorites集合按_openid goodsId查一次存在就直接返回成功不存在才add同时把goods.favoriteCount用_.inc(1)加上去。这两个写操作严格说应该在一个事务里云开发支持db.startTransaction()商品收藏数偶尔差一问题不大但要追求一致就上事务。详情页的图片预览用wx.previewImage把images数组和当前索引一起传进去用户点开能左右滑previewImage(e) { const { current } e.currentTarget.dataset; wx.previewImage({ current, urls: this.data.goods.images // fileID 数组可直接预览 }); }urls直接传云存储 fileID 是可行的小程序原生组件和 API 都认这种格式。但如果你把这个地址拼进 web-view 或者复制给别人在浏览器打开就会出现 403因为 fileID 不是公网 URL需要wx.cloud.getTempFileURL换临时链接且链接有有效期。4. 用户体系与消息闭环openid、会话集合和「我的」页面刷新前三章讲的是商品的流转这一章讲人的流转。二手交易的本质是撮合两个陌生人用户体系做不好商品列表再漂亮也没人成交。4.1 登录openid 什么时候拿昵称头像怎么拿小程序的登录没有账号密码身份就是 openid。它不能在前端直接获取必须经云函数用cloud.getWXContext()拿到。所以登录逻辑放在app.js的onLaunch里全局只跑一次// app.js App({ globalData: { openid: , hasProfile: false }, onLaunch() { if (!wx.cloud) { console.error(基础库版本过低请升级到 2.2.3 以上); return; } wx.cloud.init({ env: your-env-id, // 换成你自己的云环境 ID traceUser: true // 在控制台记录访问用户方便排查 }); this.login(); }, async login() { try { const res await wx.cloud.callFunction({ name: login }); this.globalData.openid res.result.openid; this.globalData.hasProfile res.result.hasProfile; // 页面可能比登录先就绪抛个事件让页面订阅 if (this.openidReadyCallback) this.openidReadyCallback(res.result); } catch (e) { console.error([login], e); } } });对应的云函数login做两件事返回 openid顺便检查用户档案是否存在不存在就初始化一条// cloudfunctions/login/index.js const cloud require(wx-server-sdk); cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }); const db cloud.database(); exports.main async () { const { OPENID, APPID } cloud.getWXContext(); const users db.collection(users); const found await users.where({ _openid: OPENID }).limit(1).get(); if (found.data.length 0) { await users.add({ data: { nickname: , avatar: , campus: , credit: 100, createTime: db.serverDate() } }); } return { openid: OPENID, appid: APPID, hasProfile: found.data.length 0 !!found.data[0].nickname }; };这里有个必须说清楚的变化wx.getUserProfile已经拿不到真实昵称和头像了返回的是「微信用户」和灰色默认头像。现在的做法是用头像昵称填写能力让用户自己确认!-- pages/my/my.wxml 片段 -- button open-typechooseAvatar bindchooseavataronChooseAvatar image src{{user.avatar || /images/default-avatar.png}} classavatar / /button input typenickname placeholder填写昵称 value{{user.nickname}} bindbluronNicknameBlur /open-typechooseAvatar会拉起头像选择面板bindchooseavatar回调里拿到的是临时路径需要wx.cloud.uploadFile传到云存储再存 fileID。input typenickname会触发微信的昵称快捷填充。这两处如果不改用户第一次进「我的」页面看到的是空白头像和空昵称很容易被当成半成品直接卸载。4.2 留言与私信会话集合怎么建未读数怎么算二手交易的沟通链路通常是在详情页问一句「还在吗」然后转到一对一会话。项目里用一张messages集合同时承载这两种消息用goodsId区分上下文字段说明from发送方 openidto接收方 openidgoodsId关联商品可为空纯私聊content文本内容前后端都截断到 500 字read接收方是否已读createTime服务端时间读取会话记录时要注意一对会话里的消息是双向的条件必须用_.or把「我发的」和「发给我的」都捞出来// cloudfunctions/sendMessage/index.js查询分支 const { OPENID } cloud.getWXContext(); const res await db.collection(messages) .where(_.or([ { from: OPENID, to: targetOpenid }, { from: targetOpenid, to: OPENID } ])) .orderBy(createTime, asc) .skip(page * 20) .limit(20) .get();未读数用count()而不是把消息全拉下来再过滤// 我收到的未读消息条数 const { total } await db.collection(messages) .where({ to: OPENID, read: false }) .count();这个数字挂在 tabBar 的角标上wx.setTabBarBadge({ index: 2, text: String(total) })。注意角标文本长度限制是 3 个字符超过会报错所以数量过百要显示成99。发送消息的云函数还必须做一次内容安全检测不然一条带违规信息的留言就能让整个小程序被下架这个坑在后面第 5 章会单独讲。4.3 「我的」页面onShow 才是刷新的正确时机个人中心有四个 tab我发布的、我收藏的、我卖出的、我的消息。页面切换用的是同一套 Page 内的activeTab状态不跳新页面。问题在于用户在详情页收藏了一件商品返回「我的」页面时如果不刷新收藏列表还是旧的。onLoad只会在页面第一次创建时执行tab 页被切走再切回来不会重新触发。所以刷新的时机必须放在onShow// pages/my/my.js Page({ data: { activeTab: published, list: [], page: 0, hasMore: true }, onShow() { // 每次页面可见都重置并重拉当前 tab 的第一页 this.setData({ list: [], page: 0, hasMore: true }); this.fetchCurrentTab(); }, switchTab(e) { const tab e.currentTarget.dataset.tab; if (tab this.data.activeTab) return; this.setData({ activeTab: tab, list: [], page: 0, hasMore: true }); this.fetchCurrentTab(); }, fetchCurrentTab() { const map { published: getGoodsList, favorite: getFavorites, sold: getGoodsList, message: getMessages }; wx.cloud.callFunction({ name: map[this.data.activeTab], data: { page: this.data.page, mine: true, status: this.data.activeTab sold ? off : on } }).then(res { this.setData({ list: res.result.data.list, page: this.data.page 1 }); }); } });onShow每次都重拉第一页代价是多一次请求收益是数据永远是最新的。校园场景下个人中心的数据量不大这个取舍是划算的。如果你的列表数据很重另一种做法是在收藏成功的云函数里写一个全局标记onShow时检查标记再决定要不要刷新但实现复杂度会上去。5. 避坑与排查上线前被卡住的 5 个高频问题这一章是按我自己的调试记录整理的现象都是真实会遇到的原因和解法按「先看哪里、再改哪里」的顺序写。5.1 数据与权限类问题现象一本地调试一切正常真机预览或者体验版提示「cloud function not found」或者 errCode -404011。原因是云函数只在本地模拟运行过没有真正部署到云端。开发者工具里的云函数有两种运行模式勾选了「本地调试」时根本不会走网络一旦真机运行就找不到函数。解决办法是把cloudfunctions下每个文件夹逐个右键「上传并部署云端安装依赖」并且确认app.js里的env和云开发控制台右上角显示的环境 ID 完全一致——环境 ID 少一个字符表现也是找不到函数这个报错会骗人。现象二换个账号登录居然能编辑或下架别人的商品。这是权限设计的问题常见于两种情况一是把goods集合设成了「所有用户可写」前端直接db.collection(goods).doc(id).update()就生效了二是走了云函数但函数里只根据前端传的openid判断归属。解决方法是集合权限收回成「仅创建者可写」所有写操作走云函数并且用cloud.getWXContext().OPENID做归属校验前端传的 openid 一律不信。改完之后用一个非发布者的账号实测一遍能改说明还有漏网的前端直写。5.2 渲染与交互类问题现象三列表越往下滑重复的商品越多退出再进来又正常。根因几乎都是触底加载的防抖没做。onReachBottom在快速滑动时会连续触发两个请求拿到同一个pageconcat之后就是两遍。还有一种隐蔽情况请求还没回来时用户下拉刷新了旧请求的then后到把旧数据拼到了新列表上。解决方法是给每次请求打一个递增的reqId回调里比对当前reqId是否还是最新的不是就直接丢弃结果同时loading标志位在finally里复位别放在then里异常时就不会卡死。现象四商品图片在开发者工具里正常真机上一片空白控制台没有报错。通常是两个原因叠加一是image组件的src直接用了https临时链接而临时链接有效期只有两小时列表缓存了旧链接二是用了wx.cloud.getTempFileURL批量转换但没有处理失败的项某一张失败整个数组都拿不到。稳妥做法是数据库里只存 fileID渲染时直接用 fileID小程序原生image组件支持cloud://协议只有在导出、分享、H5 场景才去换临时链接并且每次用之前现换。5.3 审核与合规类问题现象五提审被驳回理由是「涉及二手交易需补充相关资质」或「UGC 内容缺少审核机制」。二手交易类目本身不是不能做但必须如实选择服务类目并且在功能上要有内容安全能力。纯撮合、不涉及资金流转的模式类目选择上会宽松一些一旦你有下单、支付、担保交易的流程要求就完全不一样了。另一个必改项是所有用户输入的内容商品标题、描述、留言在入库前都要过一遍内容安全接口图片也要过图片检测否则一条违规内容就能把整个小程序拖下水。// 发布前做文本安全检测 try { const check await cloud.openapi.security.msgSecCheck({ content: ${title} ${desc}.slice(0, 2500) // 接口有长度上限 }); if (check.errCode ! 0) return { code: 4001, msg: 内容不合规 }; } catch (e) { // 87014 表示命中违规其他异常按不通过处理更安全 if (e.errCode 87014) return { code: 4001, msg: 内容不合规 }; console.error([msgSecCheck], e); }注意这个接口在云函数里调用需要在config.json中声明permissions.openapi为security.msgSecCheck不声明会直接报无权限这个配置项在文档里不显眼我第一次配置时找了很久。6. 二次开发把通用二手模板改成学校专属版的三个切入口拿到这套代码之后最省事的改造不是重写页面而是加维度、加状态、加约束。第一个切入口是校区维度。在goods里补campus字段发布页做成必选首页顶部加一个横向滚动的校区切换条getGoodsList的condition里多拼一个campus条件。这一步改完之后同一个学校不同校区的商品就不会混在一起用户体感提升非常明显。要注意老数据没有这个字段查询时要兼容campus为空的情况否则历史商品会整个消失。第二个切入口是把「留言」升级成带状态机的预订流程。现在商品只有on和off两个状态加一个locked买家点「我想要」时云函数把状态改成locked并记录lockedBy和lockedAt24 小时内没成交自动回退到on。这个自动回退用定时触发器实现config.json里配triggers按小时跑一次扫出超时的locked商品批量update。状态机加完交易闭环才算完整用户能明确知道「这件东西有人订了」。第三个切入口是信用与举报。users表里的credit字段初始 100被举报核实一次扣 20低于 60 的用户发布商品时强制人工审核。举报入口放在详情页右上角写进一张reports集合后台用云开发控制台人工处理就够不用做管理后台。这套东西代码量不大但对社区氛围的作用远超预期。改造之后的验证方法我一般这么做准备三个测试账号发布者、买家、路人把「发布 → 列表可见 → 详情查看 → 收藏 → 留言 → 卖家改状态 → 买家看到变化 → 路人无权限操作」这条链路手动走一遍每一步都用一个新账号验证一遍权限比写自动化脚本更快发现问题。// 定时触发器清理超时未成交的预订 // cloudfunctions/unlockGoods/config.json { triggers: [ { name: cleanLocked, type: timer, config: 0 0 * * * * * } ] }最后说个自己的教训。早年做类似项目时我图省事把集合权限开成「所有用户可读可写」测试阶段一切顺畅上线第三天就有人在商品详情里把别人的价格改成 0.01。从那以后我每次新开一个集合都强制先把权限设成最严的那一档再根据业务一格格往外放云函数里的归属校验也一样写完先自己拿另一个账号打一遍。希望帮到你。本文还有配套的精品资源点击获取