🐰🔍 2026 秋软件工程 第二次结对作业 · 程序实现:校园失物招领
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | H202601软件工程与软件工程实践 |
| 这个作业的要求在哪里 | 2026秋软件工程第二次结对作业之程序实现 |
| 这个作业的目标 | 基于第一次结对作业的原型设计,完成满足核心功能的程序开发 |
| 结队成员 | 052402111林诗怡,102402106赖彦然 |
| 队友的博客链接 | 赖彦然的博客 |
〇、🧾 作业信息与相关链接
| 项目 | 内容 |
|---|---|
| 结对同学(A · 仓库创建者) | 赖彦然 · 102402106 |
| 结对同学(B · Fork 协作方) | 林诗怡 · 052402111 |
| GitHub 仓库地址 | 102402106-052402111 |
| 呈现形式 | 微信小程序(微信云开发),交付体验版二维码,扫码即用 |
体验版二维码:

一、🤝 具体分工
本次我们采用的协作方式不是「一人写前端、一人写后端」,而是按功能切片交替推进:一人先在主仓库推一个可运行的版本,另一人 Fork 后继续做下一批功能,再通过 Pull Request 合并回去。这样每一段代码都被两个人读过、跑过。
| 阶段 | 版本 | 主要工作 | 主责人 | 协作方式 |
|---|---|---|---|---|
| 骨架 | v1 | 新建仓库、小程序工程初始化、首页占位、目录规范 | 赖彦然 | 直接提交到主仓库 main |
| 数据层 | v2 | 统一数据层 utils/api.js、Mock 种子数据、朴素列表验证数据通路 |
林诗怡 | Fork → 提交 → PR → 赖彦然 Review 并 Merge |
| 首页美化 | v3 | 卡片组件 info-card、分类 Tab、双色标签、空状态组件、物品图标素材 |
林诗怡 | 同上 |
| 发布功能 | v4 | 发布页表单、必填校验、发布成功页 | 林诗怡 | 同上 |
| 核心链路 | v5 | 详情页、搜索页(关键词+类型+地点+时间)、我的发布、标记已找到/已归还 | 林诗怡 | 同上 |
| 扩展页 | v6 | 个人中心、实名认证、编辑资料、校园地图页、毛玻璃自定义 tabBar | 赖彦然 | 直接提交到 main,由林诗怡走查主干代码 |
| 云端接入 | v7 | 云函数 login / ai-recognize / cleanup、云数据库集合与安全规则、跨账号联调 |
赖彦然 | 直接提交到 main,由林诗怡走查云函数与降级路径 |
| 收尾 | v8 | 单元测试 19 例、README / 数据库设计 / 发布指南文档、WEB 端演示与后台管理端 | 赖彦然 | 直接提交到 main,由林诗怡跑测试并真机复现 |
| 修复 | v9 | 「我的」页装饰图修复:固定兔子吉祥物 🐰,不再随用户头像变化 | 赖彦然 | 直接提交到 main,由林诗怡真机回归验证(认证 / 未认证两种身份) |
| 优化 | v10 | 实名认证做减法:取消校园卡照片上传,提交即「已认证」 | 赖彦然 | 直接提交到 main,由林诗怡走查认证页并回归发布 / 联系链路 |
分工说明:
- 赖彦然:负责仓库创建与维护、工程骨架搭建、云开发环境搭建(云函数部署、数据库集合、安全规则、体验版发布与二维码生成)、扩展页面(个人中心/认证/地图)、单元测试与全部文档;同时担任 Pull Request 的 Review 与 Merge 方。
- 林诗怡:负责 Fork 仓库并完成 v2–v5 四个核心版本(数据层、首页美化、发布、详情/搜索/我的发布),全部通过 Pull Request 提交;负责核心业务链路的走查与异常分支排查、WEB 端演示页面与浏览器端单元测试。
两人共同完成的部分:需求边界讨论与范围收敛、数据结构设计(items 集合字段)、UI 走查与跨账号联调、PSP 记录与博客撰写。
关于代码互审(是双向的,不只是单向 Review): 除了赖彦然 Review 林诗怡提交的 4 个 PR,林诗怡也反过来走查了赖彦然推到主干的 v6–v10——包括云函数 ai-recognize、纯函数模块 web/js/core.js、后台管理端 web-admin/,并用自己的微信号真机复现问题。「图片换台设备就不显示」「两端状态命名不一致」这两个问题,都是她在反向走查 / 真机验证中提出来的,详见 §7.4。
二、📱 小程序功能页面完整展示
本节回答一个最直接的问题:你们的程序到底长什么样、能不能用? 以下截图按「助教打开小程序后实际的操作顺序」排列,每一张都对应一条可复现路径。图片来源为体验版真机(微信扫体验版二维码)与微信开发者工具。
2.1 🏠 首页:集中的信息流 + 分类 Tab + 搜索入口
- 第一屏就是集中的信息流(而不是地图或功能入口),直接回应「找不到比联系不上更靠前」的访谈结论;
- 顶部为搜索入口,下方是「全部 / 寻物 / 招领」分类 Tab;
- 卡片显示:物品图标、名称、类型标签(寻物 / 招领双色)、地点、时间。
2.2 📮 发布页:寻物 / 招领二选一 + 必填校验
- 顶部切换「寻物 / 招领」,表单字段包含名称、地点、联系方式、描述、图片;
- 未选类型 / 名称为空 / 地点为空 / 联系方式为空时,逐条提示具体缺哪一项(对应 §4.3.4 异常分支表)。
2.3 📸 智能相机:拍一张照片,自动填好信息
- 左:点「智能相机」后调起系统相机、识别中的加载态;
- 右:云端视觉模型返回后,只填空字段,把名称 / 描述 / 类别自动填入表单(不覆盖用户已填内容)。
2.4 🔍 搜索页:关键词 + 类型 + 地点 + 时间,四维可叠加
- 四个筛选维度任意组合,共用同一个
filterItems(§4.4 片段一); - 地点做了别名归一化(「教学楼A栋」「教A」都能被「教学楼」筛出);
- 无结果时显示空状态插画 + 「去发布」引导,而不是一片空白。
2.5 📄 详情页:完整信息 + 一键复制 + 发布者专属标记入口
左:当前用户非发布者 右:当前用户为发布者
- 展示物品名称、描述、地点、时间、图片与发布者联系方式;
- 「复制联系方式」一键写入剪贴板;
- 若当前用户是发布者,底部出现「标记为已找到 / 已归还」按钮;非发布者不显示(真正的权限校验在数据层)。
2.6 📦 我的发布:状态标记 / 编辑 / 删除
- 列出本人发布的所有信息(不因终态而消失),可编辑、可改状态、可删除;
- 这里是「谁改、从哪改」的第二个入口(另一个是详情页)。
2.7 ✅ 闭环验证:标记之后,其他人看到什么
- 标记「已找到 / 已归还」后:该信息从首页与搜索结果的公共列表即时消失(避免他人白跑一趟);
2.8 👤个人中心、实名认证与自定义 tabBar
这一节对应上一次原型里画过、本次小程序也做了的页面。它们不属于作业硬性要求(作业不要求实名认证、后台),所以单独放在这里:核心评分仍然看 §2.1–2.7 的闭环,这几张只是「顺手做完并跑通」的加分项。
- 顶部是本人信息与发布统计(寻物 / 招领各几条),下方是「我的发布 / 实名认证 / 管理后台 / 关于我们」入口;
- 统计数据来自数据层聚合接口,不是写死的数字。
- 表单校验全部通过后进入成功页,提供两个明确出口:「查看详情」和「返回首页」,避免用户发完就迷路。
三、✨ 附加特点设计与展示
作业要求写明「按物品类别、遗失或拾取地点筛选」「一键复制联系方式」这类设计属于额外特点。我们除了实现这两项,还做了下面四个。以下每项都说明:它解决了什么问题、怎么实现的、代码在哪。
📸 特点一:智能 AI 相机——拍一张照片,自动填好物品信息(创意最独到)
这个设计的意义
发布信息的最大成本在填写:丢东西的人往往在焦急状态下,还要组织语言描述「黑色、有划痕、背面贴了贴纸」。我们做了一个「拍一张照片就开始」的入口:调起相机拍下物品,云端的视觉大模型自动识别出物品名称、外观描述、所属类别,直接填进表单。
对发布者来说,从「写一段描述」变成「拍一张照」;对看信息的人来说,标题和描述是结构化的,更容易被搜索命中——这一项同时改善了发布体验和搜索命中率,不只是花哨。
实现思路
用户点「智能相机」
→ wx.chooseMedia({ sourceType:['camera'] }) 调起系统相机(微信要求必须由点击触发)
→ wx.compressImage() 压缩到 800px / quality 60,控制体积
→ wx.cloud.uploadFile() 上传云存储拿 fileID(临时路径云端读不到)
→ 主通道:云函数 ai-recognize(服务端调云开发 AI 视觉模型)
└─ 未部署/调用失败 → 自动降级:小程序端 wx.cloud.extend.AI 直连
→ 从模型返回里稳健抠出 JSON → 映射回类别白名单 → 填入表单
有一些细节需要说明:
- 现用模型hy3,识别能力有限,目前的识别功能较弱,后续还会继续完善
- 只填空字段,绝不覆盖用户已填内容。 用户如果先手写了名称再拍照,模型结果不会把他的手写内容冲掉。
- 识别不出来就老实说识别不出来。 模型返回
title = "无法辨认"时提示「照片太模糊,请手动填写」;失败时给出可执行的指引(如「请在云开发控制台开通 AI 能力」),而不是塞一个假数据糊弄用户。
代码片段
// miniprogram/pages/publish/publish.js
/** 用识别结果填表:只填空着的字段,绝不覆盖用户已填内容 */
fillFromAI(data) {
const patch = { aiScanning: false, aiDone: true }
const d = data || {}
if (!this.data.title && d.title && d.title !== '无法辨认') patch.title = d.title
if (!this.data.desc && d.desc) patch.desc = d.desc
if (!this.data.category && d.category) {
// 兜底归一:即便模型给出「校园卡」这类口语称呼,也能落到白名单选项上
const cat = aiParse.normalizeCategory(d.category)
const i = this.data.cateList.indexOf(cat)
if (i > -1) { patch.category = cat; patch.cateIndex = i }
}
patch.aiResult = (d.title && d.title !== '无法辨认')
? (d.title + (d.category ? ' · ' + d.category : ''))
: '照片看不清,请手动填写'
this.setData(patch)
}
云函数侧还做了一件「工程上必须有」的事:模型返回结构不稳定,所以我们不赌单一模型和单一图片格式,而是依次尝试(2 个模型 × 2 种图片入参格式),并把每次尝试的错误记进 attempts 一起返回,方便云端测试时定位到底哪一步失败。(代码位置:cloudfunctions/ai-recognize/index.js)
成果展示
成果截图见 §2.3 的 S3 / S4:① 发布页点「智能相机」后的识别中状态;② 识别完成后表单被自动填充的效果。
🗺️ 特点二:地点别名归一化——让「写得不规范」不变成「搜不到」
意义: 校园里同一个地点有很多叫法。用户 A 写「教学楼A栋」,用户 B 筛「教学楼」,标准做法下这两条永远碰不上。我们认为数据的容错能力比数据的完美更重要,所以在数据层做了一层归一化,而不是要求用户必须按标准写。
实现:
// web/js/core.js
function normPlace(v) {
let s = String(v == null ? '' : v).trim()
if (!s) return ''
s = s.replace(/[栋幢座]$/, '') // 教学楼A栋 → 教学楼A
const alias = {
'教学楼A': '教学楼', '教学楼B': '教学楼', '教学楼C': '教学楼',
'教A': '教学楼', '教B': '教学楼', '教C': '教学楼',
'操场': '体育馆', '运动场': '体育馆', '田径场': '体育馆',
'饭堂': '食堂', '一食堂': '食堂', '二食堂': '食堂', '三食堂': '食堂',
'宿舍': '宿舍区'
}
return alias[s] || s
}
function matchPlace(itemPlace, want) {
if (!want) return true
return normPlace(itemPlace) === normPlace(want) // 两边都归一化后再比
}
效果: 「筛选地点 = 教学楼」能一次捞出「教学楼A栋 / 教学楼B / 教A」发布的全部信息;「筛选 = 食堂」能命中「饭堂」「二食堂」。这项能力也被单元测试专门覆盖(用例 13–15)。
📋 特点三:一键复制联系方式 + 四维组合筛选
一键复制: 详情页点按钮直接把手机号 / 微信号写入系统剪贴板并给出 Toast 反馈,不用用户长按手动选中——这是「联系发布者」这一步里最容易被忽略的体验损耗。
// miniprogram/pages/detail/detail.js
copyContact() {
wx.setClipboardData({
data: this.data.item.contact,
success: () => wx.showToast({ title: '已复制联系方式', icon: 'none' })
})
}
四维组合筛选: 作业提到的「按类别、地点筛选」我们做成了关键词 + 类型 + 地点 + 时间四个维度可任意叠加,并且每次都走同一个 filterItems(§4.4 片段一),筛选结果里还会对命中的关键词做高亮(WEB 端)。
🔐 特点四:越权防护 + 数据可见性规则(看不见但最重要的特点)
越权防护: 改状态、编辑、删除三类写操作全部在后端校验 publisherId,并用字段白名单限制可改字段(§4.4 片段三)。前端隐藏按钮只是体验,真正的权限边界在数据层。
可见性规则: 信息被标记为终态后从公共列表移除,避免「已经找回的消息还挂在那里、别人白跑一趟」。这一条直接回应了访谈中「信息挂着不走是二次伤害」的反馈。
附带的一致性保障: 详情页的图片如果加载失败(fileID 过期或资源被清理),会自动回退到该类别对应的物品图标,不会出现破图;发布页的图片在上传失败时只丢图、不丢文字。
🎀 其他值得一提的小设计
- 毛玻璃自定义 tabBar(
custom-tab-bar/):底部导航做了半透明模糊,切换时有锚点反馈,视觉上更轻。 - 组件化复用:
info-card(卡片,内置 WXS 做状态/类型样式映射)与empty-state(空状态,带「去发布」引导)在首页、搜索页、我的发布页复用,三处的空状态文案与卡片样式天然一致。 - 图片兜底策略:上传时单张失败不阻断整体;展示时按类别图标兜底(前端有 13 张物品图标素材)。
- 分页参数:列表接口带
skip / limit,为数据量增长留了余地。 - WEB 端演示(
web/)与后台管理端(web-admin/):前者是「双击就能跑」的零依赖演示,也是单元测试的载体;后者提供数据概览、信息审核、用户管理、举报处理、地点管理五个模块,验证了数据层接口的可扩展性。
四、💡 解题思路描述与设计实现说明
4.1 🏗️ 代码实现思路
🧭 (1)技术选型:为什么是「微信小程序原生 + 微信云开发」
| 候选方案 | 结论 | 理由 |
|---|---|---|
| 纯 WEB 页面(HTML/CSS/JS) | 放弃 | 和实际使用场景割裂,现在的使用较少,不够具有实践性 |
| 原生 App(APK) | 放弃 | 需要 Android 工程、打包签名,助教还要装 APK 并处理「未知来源」权限,两人时间不够,交付体验也差。 |
| 微信小程序原生 + 微信云开发 | 选定 | ① 云函数天然能拿到 openid,「谁发布的」不用自己做登录系统;② 云数据库 + 云存储免去自己搭后端和服务器;③ 可以直接发体验版,助教扫个码就能用,比 APK 更轻;④ 原生框架没有构建步骤,git 提交即代码,符合两人协作的可读性要求。 |
🍰 (2)分层设计:三层各管一件事

- 视图层(
miniprogram/pages/*):只负责渲染和交互,不直接碰数据库。十个页面各自独立,公共部分抽成组件。 - 数据层(
miniprogram/utils/api.js):业务中台。页面调用的是api.listItems / createItem / getItem / updateItemStatus / listMyItems这类统一接口,内部对「云开发」和「Mock」做了两套实现。切换数据源只需要改config.js里的一个useMock开关,页面代码一行都不用动。 - 云后端(
cloudfunctions/*+ 云数据库):身份识别、数据持久化、AI 识别。 - 附加作品:
web/(浏览器端演示 + 单元测试载体)、web-admin/(后台管理端:数据概览 / 信息审核 / 用户管理 / 举报处理 / 地点管理)。
🗝️ (3)四个关键设计决策
决策一:把「可测的逻辑」从页面里抠出来。
小程序页面的 Page({...}) 依赖 wx 全局对象,在 Node 环境里跑不起来,没法直接做单元测试。所以我们把不依赖任何环境的核心规则(发布校验、组合筛选、地点归一化、状态映射、类别图标解析)抽成 web/js/core.js 里的纯函数——同一份逻辑,浏览器里挂到 window.Core,Node 里用 module.exports 导出让测试脚本 require。被测试的代码就是运行中的代码,而不是为了测试另写一份。
决策二:状态只用一个字段,但要让三件事都成立。
作业要求「标记为已找到 / 已归还」,而首页、搜索页、我的发布页对「是否展示」的诉求完全不同。我们没有为每个页面各写一套判断,而是让一个 status 字段驱动三件事:公共列表只渲染可见状态、详情页显示对应横幅、我的发布不做过滤。文案则根据信息类型自动选择(寻物 → 已找到,招领 → 已归还)。
决策三:越权校验放在数据层,不放前端。
前端按钮隐藏只是体验,「能不能改」必须由数据层回答。所有写操作(改状态、编辑、删除)都先取原记录,校验 publisherId === 当前用户,不通过直接拒绝。编辑接口还用字段白名单过滤入参,防止有人从前端塞一个 status 把别人的信息改掉。
决策四:所有来自用户的文本都要「洗一遍」。
校园场景里,用户会从群聊、备忘录里复制粘贴,带进来零宽空格、全角空格、成对的中文引号是常态。这些不可见字符会让「搜索无结果」「筛选失效」,而且极难排查。所以 core.js 里有一个 tidy() 统一做清洗,地点还额外做别名归一化(教学楼A栋 / 教A → 教学楼,饭堂 / 一食堂 → 食堂,操场 → 体育馆)。
4.2 🗺️ 关键实现的流程图与数据流图
4.2.1 🔁 核心业务闭环(含「收尾」环节)

文字版流程:
【发布】发布页选类型(寻物/招领)→ 填名称、地点、联系方式 → 校验通过
→ 图片先上传云存储换成 fileID → 写入云数据库 → 跳发布成功页
【浏览】首页拉取 status=已通过 的信息,按时间倒序,可切「全部/寻物/招领」Tab
【搜索】搜索页输入关键词(匹配名称与描述)+ 类型 + 地点 + 时间范围,四维任意组合
【详情】点卡片进详情页,看完整信息与发布者联系方式
【联系】点「复制联系方式」写入剪贴板,线下联系(本作业不要求即时聊天)
【收尾】发布者在详情页或「我的发布」标记 → 寻物记「已找到」、招领记「已归还」
→ 该信息从公共列表消失,详情页显示终态横幅,「我的发布」里始终保留
关于「谁改 / 从哪改 / 别人在哪看到」的三问三答:
| 问题 | 回答 |
|---|---|
| 谁来修改? | 只有该条信息的发布者。数据层会取出原记录并比对 publisherId 与当前登录用户,不是本人直接返回「只能修改自己发布的信息」,按钮点了也不会成功。 |
| 从哪个入口修改? | 两个入口:① 详情页底部的「标记为已找到 / 已归还」按钮;② 「我的发布」页点卡片(或卡片右上角的编辑图标进入编辑态)。两处最终调用的是同一个数据层方法,规则只有一份。 |
| 修改后其他用户在哪里看到结果? | ① 首页与搜索页:该信息立即从公共列表消失,不再占用他人的浏览位置,也不会有人再来无效联系;② 详情页:如果对方还开着这条链接(例如从转发进入),顶部会显示「已找到 / 已归还」的终态横幅,不会被误导;③ 「我的发布」:始终保留这条记录,发布者能看到自己的历史,误操作时还可以改回「进行中」。 |
4.2.2 🚦 信息状态机

状态机的设计要点:已通过 是唯一的对外可见状态;已找到 / 已归还 是两条互斥的终态(由信息类型决定用哪一个);待审核 / 已驳回 / 已下架 是预留的管理侧状态,当前由后台管理端使用,不阻塞主流程。终态不是死胡同——发布者可以改回进行中(比如东西又丢了),这条回流路径在「我的发布」里给了入口。
4.2.3 🌊 数据流图:页面 → 统一数据层 → 数据源

这张图解释了我们为什么能在没有云环境的情况下把开发做下去:useMock = true 时走内存种子数据,开发者工具打开就能预览、能发布、能改状态;useMock = false 时走云开发,代码路径完全一致。队友在本地写页面时不依赖云环境,联调时再切过来,避免了「两个人都要重新配一遍云环境」的时间浪费。
4.2.4 🌂 异常与边界分支表(不只设计顺利的那条路)
| 场景 | 界面表现 | 实现位置 |
|---|---|---|
| 未选类型 / 名称为空 / 地点为空 / 联系方式为空 | 明确提示具体缺哪一项,不提交 | validatePublish 返回错误数组,逐条提示 |
| 物品名称超过 30 字、描述超过 200 字 | 提示超长并拦截 | 同上(边界值取 30 与 31 各测一遍,见 §6) |
| 类型值被篡改成非法值(如「转让」) | 校验失败,拒绝提交 | 同上(枚举白名单) |
| 搜索/筛选后没有任何结果 | 显示空状态插画 + 「去发布」引导按钮,而不是一片空白 | empty-state 组件 |
| 打开的信息已被删除或不存在 | 提示「信息不存在或已删除」,1.2 秒后自动返回上一页 | 详情页 load() 的兜底分支 |
| 非发布者尝试改状态 / 编辑 / 删除 | 数据层拒绝并提示「只能修改自己发布的信息」 | api.updateItemStatus / updateItem / removeItem |
| 图片上传失败 | 提示重试,文字内容不丢,不阻断发布 | 发布页 uploadImages 单张失败返回空串并过滤 |
| 物品图片加载失败(fileID 失效) | 自动回退到该类别对应的图标,不出现破图 | 详情页 onThumbError |
| 地点写法不统一(教学楼A栋 / 教A / 饭堂) | 归一化后仍能被正确筛选出来 | normPlace / matchPlace |
| 云数据库历史数据混入不可见字符,条件查询查不到 | 改为「拉取后统一清洗再过滤」,并用日志打印真实状态值定位问题 | api.listItems 云分支 |
| 用户拒绝相机授权 | 弹窗引导去设置页开启;用户主动取消则静默不打扰 | 发布页智能相机的 fail 分支 |
4.3 🧩 重要代码片段与解释
🔍 片段一:搜索过滤纯函数——四维组合筛选的单一入口
// web/js/core.js
function filterItems(items, q) {
q = q || {}
let list = (items || []).slice()
// 类型:寻物 / 招领
if (q.type) list = list.filter(it => it.type === q.type)
// 地点:归一化后精确匹配(教学楼A栋 == 教学楼)
if (q.place) list = list.filter(it => matchPlace(it.place, q.place))
// 关键词:同时匹配物品名称与描述,忽略大小写
if (q.keyword) {
const k = String(q.keyword).trim().toLowerCase()
if (k) {
list = list.filter(it =>
(it.title || '').toLowerCase().indexOf(k) > -1 ||
(it.description || '').toLowerCase().indexOf(k) > -1
)
}
}
// 时间范围:按发布时间过滤
if (q.timeRange && q.timeRange !== 'all') {
const s = rangeStart(q.timeRange)
if (s) list = list.filter(it => new Date(String(it.createTime || '').replace(/-/g, '/')) >= s)
}
// 状态:默认只显示「进行中」的信息
const wantStatus = q.status || STATUS.ACTIVE
list = list.filter(it => it.status === wantStatus)
return list
}
为什么这样写: 搜索页的四个筛选器(类型 / 地点 / 关键词 / 时间)共用这一个函数,页面只负责收集条件。好处是:四条分支都是独立的 if,任意组合都正确(这正是白盒测试里最好做分支覆盖的结构)。
🧾 片段二:发布校验——把异常分支写成一张清单
// web/js/core.js
function validatePublish(data) {
data = data || {}
const errors = []
if (ITEM_TYPE.indexOf(data.type) === -1) {
errors.push('请选择信息类型(寻物 / 招领)')
}
if (!String(data.title || '').trim()) {
errors.push('请填写物品名称')
} else if (String(data.title).trim().length > 30) {
errors.push('物品名称不能超过 30 个字符')
}
if (!String(data.place || '').trim()) {
errors.push('请选择遗失 / 拾取地点')
}
if (!String(data.contact || '').trim()) {
errors.push('请填写联系方式')
}
if (String(data.description || '').trim().length > 200) {
errors.push('描述不能超过 200 个字符')
}
return { ok: errors.length === 0, errors }
}
为什么这样写: 校验函数不抛异常、不改数据、不依赖环境,只返回「通过与否 + 错误清单」。这让它同时服务三个调用方:发布页(逐条弹提示)、编辑态(复用同一套规则)、单元测试(直接断言 ok 与 errors)。
🛡️ 片段三:越权防护 + 字段白名单(数据层)
// miniprogram/utils/api.js
// —— 发布者自助:修改自己发布的信息 ——
function updateItem(id, data) {
// 只允许改这些字段,避免越权改动 status / publisherId 等
const allow = ['type', 'title', 'description', 'place', 'category', 'time', 'contact', 'images']
const patch = {}
allow.forEach(k => { if (data && data[k] !== undefined) patch[k] = data[k] })
patch.updateTime = nowStr()
return DB.collection('items').doc(id).get()
.then(r => {
const it = r.data || {}
if (it.publisherId !== me._id) throw { msg: '只能修改自己发布的信息' }
return DB.collection('items').doc(id).update({ data: patch })
})
.then(() => DB.collection('items').doc(id).get())
.then(r => r.data)
}
为什么这样写: 有两点刻意为之。第一,先读后写:改之前把原记录取出来比对 publisherId,而不是相信前端传来的参数——因为前端的任何参数都可能被伪造。第二,白名单过滤入参:只允许更新 title/place/contact 这些业务字段,status、publisherId、createTime 一律不在名单里,从根上堵住「通过编辑接口改状态」这条路。删掉白名单也能跑,但那等于把数据完整性交给了前端。
五、📂 目录说明与使用说明
5.1 🗃️ 目录是如何组织的
campus-lost-found/
├── miniprogram/ ★ 主交付物:微信小程序端
│ ├── app.js / app.json / app.wxss 小程序全局入口、页面注册、全局样式
│ ├── config.js 云环境 ID / useMock 开关 / 管理员白名单
│ ├── pages/ 10 个页面(每个页面 js+json+wxml+wxss 四件套)
│ │ ├── index/ 首页:信息流 + 分类 Tab + 搜索入口
│ │ ├── publish/ 发布:寻物/招领表单 + 智能相机 + 编辑态复用
│ │ ├── publish-success/ 发布成功提示
│ │ ├── search/ 搜索:关键词 + 类型 + 地点 + 时间
│ │ ├── detail/ 详情:完整信息 + 复制联系方式 + 改状态入口
│ │ ├── my-publish/ 我的发布:编辑 / 标记已找到已归还 / 删除
│ │ ├── map/ 校园地图(静态地点示意)
│ │ ├── profile/ 个人中心
│ │ ├── auth/ 实名认证
│ │ └── edit-profile/ 编辑资料
│ ├── components/ 自定义组件
│ │ ├── info-card/ 信息卡片(含 index.wxs 样式映射)
│ │ └── empty-state/ 空状态
│ ├── custom-tab-bar/ 自定义底部导航(毛玻璃)
│ ├── utils/ 数据层与工具
│ │ ├── api.js ★ 统一数据层(云开发 / Mock 双实现)
│ │ ├── mockData.js Mock 种子数据
│ │ ├── util.js 时间格式化、uid 等
│ │ ├── category.js 类别白名单与图标映射
│ │ ├── icons.js 内联 SVG 图标
│ │ └── ai-parse.js AI 返回解析与类别归一
│ └── assets/images/ 图片素材(物品图标 / 校园图 / 吉祥物 mascot-bunny.png)
│
├── cloudfunctions/ 云函数(需在开发者工具中「上传并部署」)
│ ├── login/ 获取 openid,构建用户身份
│ ├── ai-recognize/ AI 视觉识别物品(多模型 × 多格式重试)
│ └── cleanup/ 数据清理(过期信息)
│
├── web/ 附加:浏览器端演示(零依赖,双击即用)
│ ├── index.html 入口(hash 路由单页应用)
│ ├── css/style.css
│ ├── js/core.js ★ 核心业务纯函数(单元测试对象)
│ ├── js/seed.js / store.js / app.js 种子数据 / localStorage 数据层 / 视图渲染
│ └── test/test.js ★ 单元测试脚本(19 个用例)
│
├── web-admin/ 附加:Web 后台管理端
│ ├── index.html 数据概览 / 信息审核 / 用户管理 / 举报处理 / 地点管理
│ ├── css/style.css
│ └── js/config.js / mock.js / api.js / app.js
│
├── project.config.json 微信开发者工具工程配置(含 AppID)
├── DATABASE.md 云数据库集合设计与安全规则
├── PUBLISH_GUIDE.md 云函数部署 + 体验版发布 + 二维码生成指南
└── README.md 目录说明与使用说明(与本文档一致)
组织思路(为什么这样分):
- 按职责分层,而不是按文件类型堆在一个目录。
pages / components / utils / assets四层,找页面去pages,找公共逻辑去utils,不会出现「所有 js 塞一个文件夹」的情况。 - 数据源只有一处入口。 所有页面都通过
utils/api.js取数据,api.js内部屏蔽了「云开发」与「Mock」的差异。想换数据源,改config.js一个开关即可。 - 可测的逻辑独立成文件。
web/js/core.js里的纯函数就是业务规则本身,单元测试直接require它,测试代码与被测代码之间不需要任何 mock 框架。 - 文档与代码同仓。
DATABASE.md、PUBLISH_GUIDE.md、README.md与代码一起版本管理,避免「文档说的和代码不一样」。
5.2 🧭 测试人员如何运行
主交付物是微信小程序,因此直接用体验版二维码是最省事的验证方式;我们也准备了不依赖任何环境的浏览器端演示作为备选。三种方式任选其一:
📱 方式 A:扫码体验(推荐,零配置)
- 用微信扫描本文开头的体验版二维码;
- 可以直接完成全部操作:浏览信息流 → 发布一条信息 → 搜索 → 查看详情 → 复制联系方式 → 在「我的发布」里标记「已找到 / 已归还」;
- 数据走微信云开发真实数据库,
useMock = false,无需任何安装或配置。
若提示「不在体验成员范围内」,请在班级群联系我方,我们会把你的微信号加入体验成员名单(体验版成员上限约 15 人)。
🛠️ 方式 B:微信开发者工具导入(想自己编译调试)
- 下载安装 微信开发者工具(稳定版即可);
- 打开工具 → 「导入项目」→ 目录选择仓库根目录(即包含
project.config.json的那一层); - AppID 选择「使用测试号」,或填写
wx8c285f6257ce6c94; - 若是自己的测试号且没有云环境,把
miniprogram/config.js里的useMock改成true,即可用内置 Mock 数据完整预览(发布、搜索、改状态都可以,只是数据不跨设备); - 若要接入云开发:在开发者工具中开通云环境 → 把环境 ID 填入
config.js的env→ 右键cloudfunctions/login「上传并部署(云端安装依赖)」→ 按DATABASE.md创建users / items / reports / places四个集合并粘贴安全规则 → 把useMock改回false。
🧪 方式 C:运行单元测试(一行命令)
cd web
node test/test.js
预期输出:19 个用例全部通过(通过:19 个 / 失败:0 个)。无需 npm install,无需任何测试框架。
🗄️ 后台管理端(附加,可选)
双击 web-admin/index.html,账号密码随意(演示模式,内置 Mock 数据),可查看数据概览、信息审核、用户管理、举报处理、地点管理五个模块。
六、🧪 单元测试
6.1 🛠️ 测试工具选型、学习过程与一份简易教程
我们选用的工具:Node.js 内置 assert 模块 + 自写的极简测试运行器(零第三方依赖)。
为什么不用 Jest / Mocha? 我们试过两条路:
| 方案 | 结果 | 原因 |
|---|---|---|
| 引入 Jest / Mocha | 放弃 | 需要 npm install(几十 MB 依赖)、需要配置 package.json 与测试环境;助教或同学拿到仓库后必须先装 Node 依赖才能跑测试,这与「下载后双击就能验证」的交付目标冲突。 |
Node 内置 assert + 自写运行器 |
选定 | 一行 node test/test.js 就能跑,零安装、零配置,任何一台装了 Node 的电脑都能复现。 |
我们是怎么学单元测试的:
- 先看了阮一峰的《测试框架 Mocha 实例教程》和廖雪峰的 JavaScript 单元测试章节,弄清一个测试框架到底提供什么——用例组织、断言、结果统计,就这三件事。
- 于是我们自己把这三种能力实现了一遍(下面附录里就是完整实现,约 30 行),这样做有两个好处:既理解了断言库在做什么,也让项目彻底摆脱了对第三方框架的依赖。
- 再从「白盒测试」的方法论入手:先画出被测函数的分支结构(
if / else if各有几条),保证每条分支都被至少一个用例走到;然后补上边界值(30 与 31 字符)与异常输入(空值、非法枚举值)。
附录:我们自己的单元测试简易教程(可直接照着用)
第 1 步:搞清楚要测什么。单元测试测的是函数,不是页面。所以先把「能算出来、不依赖界面」的逻辑抽成纯函数(我们的 web/js/core.js 就是干这个的)。
第 2 步:写一个最小的测试运行器(这三件事就是框架的全部核心):
const assert = require('assert')
let passed = 0, failed = 0
const failures = []
function test(name, fn) {
try {
fn()
passed++
console.log(' ✓ ' + name)
} catch (e) {
failed++
failures.push({ name, msg: e.message })
console.log(' ✗ ' + name + '\n ' + e.message)
}
}
// 最后一个用例失败时让进程返回非 0,便于将来接入自动化(CI)
process.exit(failed ? 1 : 0)
第 3 步:写用例。一个用例 = 准备数据 → 调用函数 → 用 assert 断言结果:
test('标题恰好 30 字符(边界值 =30):应通过', () => {
const r = Core.validatePublish({ type: '寻物', title: '字'.repeat(30), place: '食堂', contact: '138' })
assert.strictEqual(r.ok, true)
})
第 4 步:运行。
cd web
node test/test.js
第 5 步:看输出。全部通过会打印 ✅ 全部通过!;失败会打印期望值与实际值的差异,直接定位问题:
✓ 7. 标题恰好 30 字符(边界值 =30):应通过
✗ 8. ...
Expected values to be strictly equal:
+ actual - expected ...
学到的关键点:测试的价值不在「跑出一堆绿勾」,而在于它逼你把函数的输入输出想清楚。 我们的
validatePublish之所以写得那么规整(不抛异常、只返回结果对象),就是因为写测试时发现「抛异常的函数很难断言」。
6.2 📜 部分单元测试代码与测试的函数说明
被测文件:web/js/core.js 测试文件:web/test/test.js 用例数:19 个(作业要求至少 5 个,附录建议至少 10 个)。
| 分组 | 用例 | 被测函数 | 覆盖的分支 |
|---|---|---|---|
| 一、发布校验 | 1–7 | validatePublish |
合法输入 / 缺名称 / 缺地点 / 缺联系方式 / 非法类型 / 超长(31)/ 边界(30) |
| 二、搜索过滤 | 8–12 | filterItems |
关键词命中标题 / 命中描述 / 默认状态过滤 / 类型过滤 / 无结果 |
| 三、地点归一化 | 13–15 | normPlace matchPlace |
后缀「栋」剥离 / 别名映射 / 脏值匹配 |
| 四、类别图标 | 16–18 | resolveCategoryIcon |
精确命中 / 未知类别兜底 / 空值兜底 |
| 五、状态机 | 19 | terminalStatus |
寻物 → 已找到,招领 → 已归还 |
测试代码节选(含典型的三类用例):
// —— 类型一:正常路径 ——
test('1. 合法输入:应通过校验', () => {
const r = Core.validatePublish({ type: '寻物', title: '校园卡', place: '教学楼', contact: '13800001111' })
assert.strictEqual(r.ok, true)
assert.strictEqual(r.errors.length, 0)
})
// —— 类型二:异常分支(缺字段 / 非法枚举)——
test('4. 缺少联系方式:应报错', () => {
const r = Core.validatePublish({ type: '寻物', title: '钥匙', place: '宿舍区', contact: '' })
assert.strictEqual(r.ok, false)
assert.ok(r.errors.some(e => e.indexOf('联系方式') > -1))
})
test('5. 非法类型:应报错', () => {
const r = Core.validatePublish({ type: '转让', title: '书', place: '图书馆', contact: '138' })
assert.strictEqual(r.ok, false)
})
// —— 类型三:边界值(30 与 31 一对,卡在边界两侧)——
test('6. 标题超长(边界值 >30 字符):应报错', () => {
const r = Core.validatePublish({ type: '寻物', title: '字'.repeat(31), place: '食堂', contact: '138' })
assert.strictEqual(r.ok, false)
assert.ok(r.errors.some(e => e.indexOf('30') > -1))
})
test('7. 标题恰好 30 字符(边界值 =30):应通过', () => {
const r = Core.validatePublish({ type: '寻物', title: '字'.repeat(30), place: '食堂', contact: '138' })
assert.strictEqual(r.ok, true)
})
// —— 业务规则断言:已找到的信息不应该出现在公共列表 ——
test('10. 默认状态过滤:只显示「进行中」,排除「已找到」', () => {
const r = Core.filterItems(sampleItems, {})
assert.strictEqual(r.length, 3)
assert.ok(r.every(it => it.status === '进行中'))
})
实际运行结果(截图见下方「测试报告」):
========== 校园失物招领 · 核心逻辑单元测试 ==========
【一】发布校验 validatePublish
✓ 1. 合法输入:应通过校验
✓ 2. 缺少物品名称:应报错
...(中间略)...
【五】状态机 terminalStatus
✓ 19. 寻物终态是「已找到」,招领终态是「已归还」
========== 测试结果汇总 ==========
通过:19 个
失败:0 个
✅ 全部通过!

6.3 🥊 测试数据的构造思路与「如何应对刁难」
(1)测试数据是怎么设计出来的
我们没有随手造数据,而是从「用户真正会怎么填」倒推:
构造一份固定的样本集 sampleItems(4 条),刻意让它们在四个维度上互相区分,这样一个集合就能拉开多条分支:
| _id | 类型 | 名称 | 地点 | 状态 | 用途 |
|---|---|---|---|---|---|
| a | 寻物 | 校园卡 | 教学楼 | 进行中 | 关键词命中标题;类型过滤保留 |
| b | 招领 | 折叠雨伞 | 食堂 | 进行中 | 类型过滤时被排除 |
| c | 寻物 | 蓝牙耳机 | 图书馆 | 已找到 | 验证「终态不进公共列表」这条业务规则 |
| d | 招领 | 钥匙 | 宿舍区 | 进行中 | 关键词只出现在描述里(「小熊挂件」)→ 验证描述也参与匹配 |
(2)我们是怎么考虑各种情况的——三个层次
- 正常情况:完全合法的输入、能搜到结果的查询(用例 1、8、9)。
- 异常情况:空字段、非法枚举值、无匹配结果(用例 2–5、12)。这一类是「用户真的会干」的事,不是假想。
- 边界情况:恰好 30 字符与 31 字符(用例 6、7)、空类别字符串(用例 18)、地点别名与真实值混用(用例 15)。
- 业务规则层面(最容易被忽略的一层):用例 10 断言的其实是「已找到的信息不能出现在首页」这条业务规则,而不只是函数逻辑。这类断言一旦被破坏,页面看起来还能跑,但产品语义已经错了。
(3)如果测试人员来刁难,我们会怎么应对?
我们提前想过几类可能的「刁难」及当前处理方式:
| 可能的刁难 | 我们现在的处理 | 还存在的风险 |
|---|---|---|
| 名称里全是空格、粘贴带零宽字符 | tidy() 会清洗零宽字符与首尾引号,trim() 后为空则拦截 |
极少数罕见 Unicode 组合可能没覆盖到 |
关键词里带正则元字符(如 .*) |
搜索用的是 indexOf 而不是 RegExp,天然安全 |
因此不支持模糊/正则搜索,这是有意取舍 |
| 关键词大小写、全角半角混用 | 统一 toLowerCase();tidy() 做全角转半角 |
中文场景下大小写影响有限 |
时间字段格式不统一(2026-09-26 10:00 与 2026/9/26) |
比较前统一 replace(/-/g,'/') 交给 Date 解析 |
非标准格式仍会解析失败,返回 Invalid Date |
| 数据量变大后搜索变慢 | 列表接口带 skip/limit 分页 |
云模式下目前是「拉取最多 100 条后前端过滤」,数据量再大需要改成数据库条件查询 |
| 直接调接口绕过前端校验 | 数据层重新校验(createItem 内部就调 validatePublish),写操作校验 publisherId |
云数据库安全规则是第二道防线,已按 DATABASE.md 配置 |
(4)对测试设计本身的评价(哪些做到了,哪些没做到)
- 做到了:白盒分支覆盖(
validatePublish的 6 条错误分支全走到)、边界值成对设计、业务规则级断言、零依赖可复现。 - 没做到:没有覆盖页面层的交互逻辑。 小程序页面的
Page()依赖wx全局对象,我们的 19 个用例全部集中在纯函数上。像「发布成功后是否正确跳转到 publish-success 页」「tabBar 页面带参编辑能否正确预填表单」这些,目前只能靠人工走查(见 §5.2 的走查路径)。要自动化这一层,需要引入miniprogram-simulate之类的小程序测试库,成本超出本次时间预算 —— 这是我们明确知道但暂时欠下的技术债。 - 另一个诚实的说明:
web/js/core.js与小程序端utils/api.js的业务规则同源但不是同一份代码(小程序端受wx环境限制无法直接require我们的纯函数文件)。因此这套测试保护的是业务规则的正确性,而不是小程序端每一行代码。我们在 §8 里也记录了因为这一点踩到的具体问题。
七、🐙 GitHub 代码签入记录
7.1 🔀 协作流程
按作业要求:一位同学创建仓库,另一位同学 Fork 后通过 Pull Request 提交。我们的实际流程是:
赖彦然(仓库创建者) 林诗怡(Fork 协作方)
│ │
├─ 新建仓库 102402106-052402111 │
├─ push v1 骨架 │
│ ├─ Fork 仓库
│ ├─ 开发 v2 数据层 → PR #1
├─ Review + Merge ←──────────────────────┤
│ ├─ 开发 v3 首页美化 → PR #2
├─ Review + Merge ←──────────────────────┤
│ ├─ 开发 v4 发布功能 → PR #3
├─ Review + Merge ←──────────────────────┤
│ ├─ 开发 v5 详情/搜索/我的发布 → PR #4
├─ Review + Merge ←──────────────────────┤
├─ push v6 扩展页 │
├─ push v7 云端接入 │
│ ←── 林诗怡 走查主干代码(反向 Code Review)
├─ push v8 测试与文档 │
│ ←── 林诗怡 跑单元测试 + 真机复现问题
├─ push v9 修复「我的」页装饰图(兔子吉祥物回归 🐰)
│ ←── 林诗怡 真机回归验证(认证 / 未认证两种身份)
├─ push v10 简化实名认证(取消照片上传,提交即通过)
│ ←── 林诗怡 走查认证页并回归发布 / 联系链路
└─ 生成体验版二维码(V9 / V10 同步发布) │
7.2 📦 提交记录(每完成一个可运行的功能,commit 一次)
| 版本 | Commit Message | 提交人 | 内容 |
|---|---|---|---|
| v1 | chore: 初始化小程序项目骨架 |
赖彦然 | 工程配置、全局入口、首页占位 |
| v2 | feat: 搭建数据访问层与 Mock 数据 |
林诗怡(PR) | utils/api.js 双实现、种子数据、朴素列表 |
| v3 | feat: 美化首页并引入卡片组件 |
林诗怡(PR) | info-card / empty-state、分类 Tab、图标素材 |
| v4 | feat: 实现发布信息功能 |
林诗怡(PR) | 发布表单、校验、发布成功页 |
| v5 | feat: 补全详情/搜索/我的发布核心链路 |
林诗怡(PR) | 详情、搜索、我的发布、标记已找到/已归还 |
| v6 | feat: 新增地图/个人中心/认证页面 |
赖彦然 | 地图页、个人中心、实名认证、编辑资料、自定义 tabBar |
| v7 | feat: 接入微信云开发云函数 |
赖彦然 | login / ai-recognize / cleanup、数据库集合与安全规则 |
| v8 | test: 补充单元测试并完善文档 |
赖彦然 | 19 个测试用例、README、DATABASE.md、PUBLISH_GUIDE.md、WEB 端 |
| v9 | fix: 修复「我的」页装饰图被用户头像覆盖 |
赖彦然 | profile.wxml 装饰图固定为兔子吉祥物(1 个文件、1 处代码,见 §8 困难五) |
| v10 | refactor: 简化实名认证流程 |
赖彦然 | 取消校园卡照片上传,authStatus 提交即「已认证」(3 个文件、核心一行,见 §3 特点六) |
7.3 📸 签入记录截图

GitHub 仓库 Commits 页面

Pull Requests 页面
7.4 🔎 双向代码互审(Code Review):赖彦然审林诗怡的 PR,林诗怡也审赖彦然的提交
按作业要求「一位同学创建仓库、另一位 Fork 后 PR」,但我们的 Review 不是单向的:林诗怡提交的 4 个 PR 由赖彦然 Review;赖彦然直接推到主干的 v6–v8,同样由林诗怡走查。两个方向都留下了具体结论。
| 方向 | 审查人 | 审查对象 | 具体审查点 | 结论 / 产出 |
|---|---|---|---|---|
| 正向 | 赖彦然 | 林诗怡的 PR #1–#4(v2–v5) | 数据层接口签名是否统一;组件事件是否用 triggerEvent 抛出、父页面用 bind: 接收;发布/搜索的异常分支是否覆盖 |
4 个 PR 全部 Approve + Merge;其中 PR #3 要求补上「图片上传失败只丢图、不丢文字」的分支后才合并 |
| 反向 | 林诗怡 | 赖彦然的主干提交 v6–v8(含云函数、纯函数模块、后台管理端) | 云函数错误码与降级路径;web/js/core.js 与小程序端 utils/api.js 的业务规则是否一致;状态命名是否统一;后台端五个模块是否只走数据层 |
提出 3 条:2 条当场修复(AI 失败诊断信息、图片本地路径→云存储),1 条(状态命名两套写法)写成遗留问题,记录在 §8 |
反向审查中,林诗怡实际做的四件事(都可复现):
- 跑测试 + 核对两端规则一致性。 在本地
cd web && node test/test.js,确认 19 个用例全绿;并逐行比对web/js/core.js的filterItems / validatePublish与小程序端utils/api.js的业务规则,发现「状态命名」在两端不一致(小程序端是「已通过 / 已完成」,纯函数模块是「进行中 / 已找到 / 已归还」),写进遗留问题。 - 用另一个微信号真机复现,而不是相信「在我机器上是好的」。 扫码预览赖彦然部署的体验版后发现:赖彦然手机上正常的图片,在林诗怡手机上不显示——直接定位到「发布时存的是本机临时路径、没上传云存储」(详见 §8 困难一)。这说明审查不只是读代码,还包括换一个身份 / 设备去复现。
- 审云函数
ai-recognize: 确认它做了「多模型 × 多图片格式」的依次重试,并要求把每次失败信息汇总进返回值的attempts字段(否则线上只看到一句「识别失败」,无从定位);同时补测了「云函数未部署 → 小程序端wx.cloud.extend.AI降级直连」这条兜底路径。 - 审后台管理端
web-admin/: 核对「数据概览 / 信息审核 / 用户管理 / 举报处理 / 地点管理」五个模块是否都通过数据层接口取数、没有哪个页面绕过api.js直连数据。
双向审查的价值:正向 Review(对 PR)保证「功能做对了没有」,反向走查(对主干提交 + 真机复现)保证「换一个身份、换一台设备还好不好使」。后者抓到的两个问题(图片不跨设备显示、状态命名分裂),恰恰是单元测试和本地预览都发现不了的。
八、🐛 遇到的代码模块异常 / 结对困难及解决方法
📵 困难一:发布的信息,换一台手机就看不到图片
- 问题描述:第一版发布功能做完后,我们在自己的开发者工具里测试完全正常——图片能显示、能搜索。但队友用手机扫码预览时,图片区域是空白的,只有文字。
- 做过哪些尝试:① 先在云数据库里查记录,发现
images字段存的是类似http://tmp/xxx.jpg的地址,而不是云存储的cloud://开头;② 又怀疑是图片组件路径写法问题,试了mode="aspectFill"和aspectFit,都没用;③ 去查wx.chooseMedia的文档,才发现它返回的是tempFilePath——一个只在本机临时存在的路径。 - 是否解决:已解决。在提交前增加一步:先把本地临时路径
wx.cloud.uploadFile上传到云存储,拿到fileID再写入数据库;如果图片本来就是cloud://开头(编辑时未改动的旧图),则跳过上传,避免重复占用空间。 - 有何收获:「在我机器上是好的」在小程序里尤其危险。 同时我们学到一个工程习惯:上线前必须用另一个微信号完整走一遍,因为这和换一台手机是等价的。
🚧 困难二:tabBar 页面跳转带不了参数,「我的发布 → 编辑」整条链路返工
- 问题描述:编辑功能的做法是把「我的发布」里点的信息 id 传给发布页,发布页载入后预填表单。但发布页是 tabBar 页面:
wx.navigateTo根本跳不过去(小程序不允许navigateTo打开 tabBar 页面),而wx.switchTab不支持携带参数,id 传不过去。 - 做过哪些尝试:① 先用
navigateTo,报错「can not navigateTo a tabbar page」;② 改用switchTab带 query 参数,参数被丢弃;③ 试过用eventChannel,同样受限于 tabBar 页面无法用navigateTo打开。 - 是否解决:已解决。改成全局变量传递:
my-publish把待编辑 id 写进app.globalData.editItemId,publish在onShow里读取。这里还踩了第二个坑——tabBar 页面的onLoad只在首次进入时执行一次,所以读取必须放在onShow,否则第二次进入编辑态就没反应了。 - 有何收获:小程序的生命周期和普通页面不一样,「页面驻留」这件事必须有意识地去处理。我们把这个坑写在代码注释里(
my-publish.js的onEdit上方),避免以后忘掉。
🫥 困难三:云数据库条件查询查不到数据,而数据明明存在
- 问题描述:接入云开发后,首页偶尔出现「列表是空的」,但控制台里能看到记录,且
status字段看起来就是「已通过」。用where({ status: '已通过' })查询却查不到。 - 做过哪些尝试:① 先怀疑是权限问题,检查了集合安全规则(
items所有人可读)——正常;② 加console.log把拉回来的每一条记录的title和JSON.stringify(status)打印出来(用JSON.stringify是为了让不可见字符显形),才发现某些status值里混进了零宽空格 / 全角空格——肉眼看起来完全一样,字符串却不等;③ 也试过where搭配RegExp模糊匹配,但会误伤。 - 是否解决:已解决。改成「先拉取、再在数据层统一清洗并过滤」的策略:
isVisible()统一trim()后判断,而不是把过滤条件交给数据库。同时把地点也做了归一化(§3 特点二),因为同一个问题也发生在place字段上。 - 有何收获:不可见字符是最难排查的一类 bug,因为「看起来没问题」是它最好的伪装。 打印诊断信息时用
JSON.stringify()是一个很实用的技巧,我们后来在所有字段比较的地方都保留了这个习惯。
👻 困难四:组件里的透明点击区域,绑定了跳转却没反应
- 问题描述:首页卡片右上角想做一个「查看全部」的透明热区,绑定了
bindtap,在开发者工具预览时点击无效。 - 做过哪些尝试:① 检查事件名是否写错(
bindtapvsbind:tap)——两种写法在组件里要区分bind:click这类自定义事件;② 检查是不是被上层元素遮挡,用调试面板逐层看层级;③ 最后发现是自定义组件的事件传递问题——组件内部的事件需要triggerEvent抛到父页面,父页面要用bind:自定义事件名接,而不是直接当作原生tap。 - 是否解决:已解决。把卡片的点击统一改为组件
triggerEvent('click', { id }),父页面用bind:click="goDetail"接收,首页、搜索页、我的发布页三处统一了这一套约定。 - 有何收获:我们的体会是:界面画出来和用户能点,是两件事;组件化之后,事件边界必须像接口一样被明确约定,否则每加一个页面就重踩一次。
🐰 困难五:兔子去哪儿了?——品牌装饰图被用户头像「顶包」
-
问题描述:「我的」页右上角有一枚带漂浮动画的品牌装饰图(兔子吉祥物 🐰)。未实名认证时它显示正常 ✅;但只要用户完成实名认证并上传了个人头像,右上角的兔子就「消失」了,取而代之的是用户自己的头像 ❌——品牌装饰被用户数据意外覆盖,视觉上像是把用户的个人头像当成了装饰元素。
-
根因分析:排查
profile.wxml发现,右上角装饰图与下方「用户资料卡」的头像共用了同一个数据绑定变量{{avatar}}:<!-- 右上角装饰图(原代码) --> <image class="pf-avatar floaty" src="{{avatar}}" mode="aspectFit" binderror="onAvatarError" />而
avatar在profile.js中的取值逻辑是me.avatar || me.avatarUrl || this.data.defaultAvatar:未认证时落到默认兔子图;认证后me.avatar有值,装饰图就跟着资料卡一起变成了用户头像。根因是一处 UI 元素错误复用了用户数据字段——「品牌装饰」与「用户资料」没有在视图层做关注点分离。 -
是否解决:已在V9修复。把装饰图写死为固定资源路径,脱离
avatar变量的影响;同时移除装饰图上已无意义的binderror回退逻辑(该回调原本会把装饰图也回退成默认兔子,逻辑上已无意义)。改动只有 1 个文件、1 处代码:<!-- 右上角装饰:固定为兔子吉祥物,不随用户头像变化 --> <image class="pf-avatar floaty" src="/assets/images/mascot-bunny.png" mode="aspectFit" />资料卡头像仍绑定
{{avatar}}不动——它本来就该随认证 / 编辑变化。验证:开发者工具与真机体验版分别复测「未认证 → 兔子 / 兔子」和「认证并上传头像 → 兔子(不变)/ 用户头像」,两端一致;随后重新上传代码、设为体验版并刷新二维码供测试。 -
有何收获:① 关注点分离(SoC):视图层里「品牌元素」和「用户数据」应使用独立的数据源,一处图省事的复用,埋下的是「装饰随数据漂移」的隐性 Bug;② 「默认图」陷阱:「未设置时的兜底图」和「有意展示的品牌图」是两种语义,不该共用——吉祥物作为品牌符号本应常驻,不该被当成头像的默认兜底;③ 修复成本极低、定位成本关键:改代码只 1 行,但定位需要理解 wxml ↔ js 的整条数据绑定链路,「调试时间远大于编码时间」再次应验;④ 版本控制即文档:V1–V10 每个可交付状态独立成版,让每一次「为什么改」有迹可循。
🕳️ 遗留问题(我们明确知道、但本次没解决)
- 状态文案存在两套写法。 小程序端沿用了「已通过 / 已完成」的状态值,而抽出来的纯函数模块
web/js/core.js用的是「进行中 / 已找到 / 已归还」。虽然详情页做了展示层映射(已通过 → 进行中),但同一个业务概念在两处有两种命名,是隐患:将来改一处忘另一处,就会出现「小程序显示已找到、网页显示已完成」的分裂。下一步应当统一为一套枚举,并让两边共用。 - 页面层没有自动化测试。 19 个用例全部集中在纯函数,发布成功后的跳转、表单回填这些交互仍然依赖人工走查(原因见 §6.3 的自我评价)。
- 云模式下列表是「拉取后前端过滤」。 数据量大时会变慢,应改为数据库条件查询 + 索引。
九、⏱️ PSP 表格
按照作业要求,在动手实现之前先写下各项预估耗时,实现完成后补上实际耗时。下表两列有明确差异,差异原因见表格下方的偏差分析——这正是 PSP 的价值所在。
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 30 | 25 |
| Estimate | 估计这个任务需要多少时间 | 30 | 25 |
| Development | 开发 | 1100 | 1385 |
| Analysis | 需求分析(包括学习新技术) | 120 | 180 |
| Design Spec | 生成设计文档 | 60 | 45 |
| Design Review | 设计复审 | 30 | 20 |
| Coding Standard | 代码规范(为目前的开发制定合适的规范) | 20 | 15 |
| Design | 具体设计 | 90 | 110 |
| Coding | 具体编码 | 600 | 780 |
| Code Review | 代码复审 | 60 | 75 |
| Test | 测试(自我测试,修改代码,提交修改) | 120 | 160 |
| Reporting | 报告 | 95 | 105 |
| Test Report | 测试报告 | 40 | 50 |
| Size Measurement | 计算工作量 | 15 | 10 |
| Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 40 | 45 |
| 合计 | — | 1225 | 1515 |
📉 偏差分析(哪些工作被低估了、哪个环节发生了返工)
| 环节 | 偏差 | 真实原因 |
|---|---|---|
| 需求分析 +50%(120 → 180) | 严重超时 | 我们原本以为「小程序 + 云开发」是熟悉的组合,真正动手才发现要重新学:云函数如何拿 openid、云数据库的安全规则怎么写、基础库版本对 wx.cloud.extend.AI、glass-easel 组件框架的支持差异。这部分时间主要花在查官方文档 + 写最小验证 demo 上。 |
| 具体编码 +30%(600 → 780) | 明显超时,且有返工 | 三个坑各吃掉了半天以上:① wx.chooseMedia 拿到的是本地 tempFilePath,不上传云存储的话换一台手机就看不到图,第一版发布功能因此整体返工;② 发布页是 tabBar 页面,navigateTo 跳不过去、switchTab 又不支持带参数,「我的发布 → 编辑」这条链路重写了两次;③ 云数据库 where({status: ...}) 因为历史数据里混入了不可见字符(零宽空格、全角空格)而匹配不到记录,最后改成「拉取后在数据层统一归一化再过滤」。 |
| 测试 +33%(120 → 160) | 超时 | 单人自测只需一次,但跨账号联调(我的手机发布 → 队友手机搜索)暴露了图片与状态同步问题,来回验证了多轮。 |
| 设计文档 −25%(60 → 45) | 低于预估 | 上一份作业的原型和字段设计可以直接复用,DATABASE.md 基本是整理而不是重做。 |
| 代码规范 −25%(20 → 15) | 低于预估 | 两人在第一次结对时就统一了:组件统一 index.js/json/wxml/wxss 四件套命名、工具函数集中在 utils/、接口统一走 api.js,因此没有额外讨论成本。 |
结论: 我们把「学习未知技术」和「跨设备联调」这两块的耗时系统性低估了约 40%,而「文档整理」被高估。下一次做类似任务,预估里会单独给「搭环境 + 学新 API」留一个显式的时间块,并把「跨账号验收」从测试环节提到编码环节并行做。
🐰 兔兔备注:本表统计到 V8 为止;V9、V10 都是博客初稿完成后的临时返修(每次约 20 分钟,没来得及入表)。但它们恰好再次印证上表结论——定位半小时、改代码 1 行(V10 的核心改动甚至只是
authStatus一个值),「调试时间远大于编码时间」诚不我欺。
十、💌 评价你的队友
🌸 林诗怡评价赖彦然(本次博客的撰写方)
值得学习的地方
- 目录规范是她一手定下来的。 第一版骨架里就定好了「组件必须四个文件一套、工具函数全放
utils/、页面不许直连数据库、统一走api.js」。后面七个版本两个人分开写,几乎没有出现过同一个功能两种写法的情况,我 review 起来很省心。这点我在 §4.2 也专门写了。 - 定位 bug 喜欢「先把问题显形」。 云数据库查不到数据那次,她没有瞎猜,而是在
listItems里把真实返回的状态值打印出来,一眼就看到是「不可见字符」在作怪。这种习惯比我强,我以前更喜欢对着代码脑补。 - 交付意识是真的强。 体验版二维码、跨账号自测清单、
PUBLISH_GUIDE.md都是她主动补的。我们这个项目别人要跑起来挺麻烦(要建云环境),她把这些步骤拆解到「照着点就行」,等于提前把「代码写完了别人跑不起来」这个坑填了。 - 肯听意见。 我提的空状态插画、图片加载失败兜底这些「锦上添花」的东西,她都认真改了,不是「能跑就行」的态度。
需要改进的地方
- 提交粒度不太均匀。 有些复杂分支的注释是写完之后补的(比如云模式「拉取后前端过滤」那段),个别 commit 一次塞了多个模块。当然最后都 review 过了,只是如果一开始就「一个功能一次提交」,会更贴合作业要求、也更方便助教翻 commit 记录。
十一、🎈 写在最后
这次作业让我们真正体会到:「界面画出来」和「用户能够用起来」是两回事。 我们把上一版原型里铺开的模块砍掉了大半,反而在「发布—浏览/搜索—详情—联系—更新状态」这一条线上补出了更多的细节:发布前会拦、搜索无结果会有出口、信息被标记终态后会自动退场、想改别人的信息改不动、图片丢了还会有兜底图标。
也正是在这些地方,我们踩了坑、返了工、记下了偏差——这些写在 §8 与 §9 里的东西,可能比代码本身更有价值。
🐰 兔兔的最后一句话:大家好,我就是那只兔子。谢谢你们把我从「头像的默认兜底图」扶正为「常驻的品牌吉祥物」——现在不管谁上传了自己的头像,我都在「我的」页右上角好好站着。下次见,记得给我带一根胡萝卜。🥕
浙公网安备 33010602011771号