Ch4 软工结对——🐰L&L的失物招领小程序开发

项目 内容
这个作业属于哪个课程 H202601软件工程与软件工程实践
这个作业的要求在哪里 2026秋软件工程第二次结对作业之程序实现
这个作业的目标 基于第一次结对作业的原型设计,完成满足核心功能的程序开发
结队成员 052402111林诗怡,102402106赖彦然
队友的博客链接 林诗怡的博客

〇、🧾 作业信息与相关链接

项目 内容
结对同学(A · 仓库创建者) 赖彦然 · 102402106
结对同学(B · Fork 协作方) 林诗怡 · 052402111
GitHub 仓库地址 https://github.com/Vivian-azad/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 「我的」页装饰图修复:固定兔子吉祥物 🐰,不再随用户头像变化(详见 §8 困难五) 赖彦然 直接提交到 main,由林诗怡真机回归验证(认证 / 未认证两种身份)
优化 v10 实名认证做减法:取消校园卡照片上传,提交即「已认证」 赖彦然 直接提交到 main,由林诗怡走查认证页并回归发布 / 联系链路

分工说明:

  • 赖彦然:负责仓库创建与维护、工程骨架搭建、云开发环境搭建(云函数部署、数据库集合、安全规则、体验版发布与二维码生成)、扩展页面(个人中心/认证/地图)、单元测试与全部文档;同时担任 Pull Request 的 Review 与 Merge 方。
  • 林诗怡:负责 Fork 仓库并完成 v2–v5 四个核心版本(数据层、首页美化、发布、详情/搜索/我的发布),全部通过 Pull Request 提交;负责核心业务链路的走查与异常分支排查。

两人共同完成的部分:需求边界讨论与范围收敛、数据结构设计(items 集合字段)、UI 走查与跨账号联调、PSP 记录与博客撰写。

关于代码互审(是双向的,不只是单向 Review): 除了赖彦然 Review 林诗怡提交的 4 个 PR,林诗怡也反过来走查了赖彦然推到主干的 v6–v10——包括云函数 ai-recognize、纯函数模块 web/js/core.js,并用自己的微信号真机复现问题。「图片换台设备就不显示」「两端状态命名不一致」这两个问题,都是她在反向走查 / 真机验证中提出来的,详见 §7.4。



二、📱 小程序功能页面完整展示

本节回答一个最直接的问题:你们的程序到底长什么样、能不能用? 以下截图按「助教打开小程序后实际的操作顺序」排列,每一张都对应一条可复现路径。图片来源为体验版真机(微信扫体验版二维码)与微信开发者工具。

截图范围参考了上一次结对作业的原型(首页信息流、发布页 + AI 相机、搜索筛选、详情页、我的发布、个人中心、实名认证、自定义 tabBar、后台管理端),但本次只有核心闭环(S1–S8)作为必展示项

2.1 🏠 首页:集中的信息流 + 分类 Tab + 搜索入口

00
  • 第一屏就是集中的信息流(而不是地图或功能入口),直接回应「找不到比联系不上更靠前」的访谈结论;
  • 顶部为搜索入口,下方是「全部 / 寻物 / 招领」分类 Tab;
  • 卡片显示:物品图标、名称、类型标签(寻物 / 招领双色)、地点、时间。

2.2 📮 发布页:寻物 / 招领二选一 + 必填校验

00
  • 顶部切换「寻物 / 招领」,表单字段包含名称、地点、联系方式、描述、图片;
  • 未选类型 / 名称为空 / 地点为空 / 联系方式为空时,逐条提示具体缺哪一项(对应 §4.3.4 异常分支表)。

2.3 📸 智能相机:拍一张照片,自动填好信息

00
  • 左:点「智能相机」后调起系统相机、识别中的加载态;
  • 右:云端视觉模型返回后,只填空字段,把名称 / 描述 / 类别自动填入表单(不覆盖用户已填内容)。

2.4 🔍 搜索页:关键词 + 类型 + 地点 + 时间,四维可叠加

00
  • 四个筛选维度任意组合,共用同一个 filterItems(§4.4 片段一);
  • 地点做了别名归一化(「教学楼A栋」「教A」都能被「教学楼」筛出);
  • 无结果时显示空状态插画 + 「去发布」引导,而不是一片空白。

2.5 📄 详情页:完整信息 + 一键复制 + 发布者专属标记入口

00
  • 展示物品名称、描述、地点、时间、图片与发布者联系方式;
  • 「复制联系方式」一键写入剪贴板;
  • 若当前用户是发布者,底部出现「标记为已找到 / 已归还」按钮;非发布者不显示(真正的权限校验在数据层)。

2.6 📦 我的发布:状态标记 / 编辑 / 删除

00
  • 列出本人发布的所有信息(不因终态而消失),可编辑、可改状态、可删除;
  • 这里是「谁改、从哪改」的第二个入口(另一个是详情页)。

2.7 ✅ 闭环验证:标记之后,其他人看到什么

已归还_compressed

  • 标记「已找到 / 已归还」后:该信息从首页与搜索结果的公共列表即时消失(避免他人白跑一趟);
  • 若从转发链接进入详情,顶部显示终态横幅,不会被误导;

2.8 👤(附加模块)个人中心、实名认证与自定义 tabBar

这一节对应上一次原型里画过、本次小程序也做了的页面。它们不属于作业硬性要求(作业不要求实名认证、后台),所以单独放在这里:核心评分仍然看 §2.1–2.7 的闭环,这几张只是「顺手做完并跑通」的加分项。

00
  • 顶部是本人信息与发布统计(寻物 / 招领各几条),下方是「我的发布 / 实名认证 / 管理后台 / 关于我们」入口;
  • 统计数据来自数据层聚合接口,不是写死的数字。
00
  • 表单校验全部通过后进入成功页,提供两个明确出口:「查看详情」和「返回首页」,避免用户发完就迷路。
00

三、✨ 附加特点设计与展示

作业要求写明「按物品类别、遗失或拾取地点筛选」「一键复制联系方式」这类设计属于额外特点。我们除了实现这两项,还做了下面两个。以下每项都说明:它解决了什么问题、怎么实现的、代码在哪。

📸 特点一:智能 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)

🗺️ 特点二:地点别名归一化——让「写得不规范」不变成「搜不到」

意义: 校园里同一个地点有很多叫法。用户 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,为数据量增长留了余地。


四、💡 解题思路描述与设计实现说明

4.1 🏗️ 代码实现思路

🧭 (1)技术选型:为什么是「微信小程序原生 + 微信云开发」

候选方案 结论 理由
纯 WEB 页面(HTML/CSS/JS) 放弃 和实际使用场景割裂,现在的使用较少,不够具有实践性
原生 App(APK) 放弃 需要 Android 工程、打包签名,助教还要装 APK 并处理「未知来源」权限,两人时间不够,交付体验也差。
微信小程序原生 + 微信云开发 选定 ① 云函数天然能拿到 openid,「谁发布的」不用自己做登录系统;② 云数据库 + 云存储免去自己搭后端和服务器;③ 可以直接发体验版,助教扫个码就能用,比 APK 更轻;④ 原生框架没有构建步骤,git 提交即代码,符合两人协作的可读性要求。

🍰 (2)分层设计:三层各管一件事

6f59922593ce11d0eb42163a01c3b5af

  • 视图层(miniprogram/pages/*):只负责渲染和交互,不直接碰数据库。十个页面各自独立,公共部分抽成组件。
  • 数据层(miniprogram/utils/api.js):业务中台。页面调用的是 api.listItems / createItem / getItem / updateItemStatus / listMyItems 这类统一接口,内部对「云开发」和「Mock」做了两套实现。
  • 云后端(cloudfunctions/* + 云数据库):身份识别、数据持久化、AI 识别。

🗝️ (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 🔁 核心业务闭环(含「收尾」环节)

aaff501a22e980d391ccba99d60f7945

文字版流程:

【发布】发布页选类型(寻物/招领)→ 填名称、地点、联系方式 → 校验通过
        → 图片先上传云存储换成 fileID → 写入云数据库 → 跳发布成功页
【浏览】首页拉取 status=已通过 的信息,按时间倒序,可切「全部/寻物/招领」Tab
【搜索】搜索页输入关键词(匹配名称与描述)+ 类型 + 地点 + 时间范围,四维任意组合
【详情】点卡片进详情页,看完整信息与发布者联系方式
【联系】点「复制联系方式」写入剪贴板,线下联系
【收尾】发布者在详情页或「我的发布」标记 → 寻物记「已找到」、招领记「已归还」
        → 该信息从公共列表消失,详情页显示终态横幅,「我的发布」里始终保留

关于「谁改 / 从哪改 / 别人在哪看到」的三问三答:

问题 回答
谁来修改? 只有该条信息的发布者。数据层会取出原记录并比对 publisherId 与当前登录用户,不是本人直接返回「只能修改自己发布的信息」,按钮点了也不会成功。
从哪个入口修改? 两个入口:① 详情页底部的「标记为已找到 / 已归还」按钮;② 「我的发布」页点卡片(或卡片右上角的编辑图标进入编辑态)。两处最终调用的是同一个数据层方法,规则只有一份。
修改后其他用户在哪里看到结果? ① 首页与搜索页:该信息立即从公共列表消失,不再占用他人的浏览位置,也不会有人再来无效联系;② 详情页:如果对方还开着这条链接(例如从转发进入),顶部会显示「已找到 / 已归还」的终态横幅,不会被误导;③ 「我的发布」:始终保留这条记录,发布者能看到自己的历史,误操作时还可以改回「进行中」。

4.2.2 🚦 信息状态机

568b0245a7704897d0499a2a30e7a7e9

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

4.2.3 🌊 数据流图:页面 → 统一数据层 → 数据源

81128b80a2b98a466fdb6523c567451e

这张图解释了我们为什么能在没有云环境的情况下把开发做下去: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                       目录说明与使用说明(与本文档一致)

组织思路(为什么这样分):

  1. 按职责分层,而不是按文件类型堆在一个目录。 pages / components / utils / assets 四层,找页面去 pages,找公共逻辑去 utils,不会出现「所有 js 塞一个文件夹」的情况。
  2. 数据源只有一处入口。 所有页面都通过 utils/api.js 取数据,api.js 内部屏蔽了「云开发」与「Mock」的差异。想换数据源,改 config.js 一个开关即可。
  3. 可测的逻辑独立成文件。 web/js/core.js 里的纯函数就是业务规则本身,单元测试直接 require 它,测试代码与被测代码之间不需要任何 mock 框架。
  4. 文档与代码同仓。 DATABASE.md、PUBLISH_GUIDE.md、README.md 与代码一起版本管理,避免「文档说的和代码不一样」。

5.2 🧭 测试人员如何运行

主交付物是微信小程序,因此直接用体验版二维码是最省事的验证方式;我们也准备了不依赖任何环境的浏览器端演示作为备选。三种方式任选其一:

📱 方式 A:扫码体验(推荐,零配置)

  1. 用微信扫描本文开头的体验版二维码;
  2. 可以直接完成全部操作:浏览信息流 → 发布一条信息 → 搜索 → 查看详情 → 复制联系方式 → 在「我的发布」里标记「已找到 / 已归还」;
  3. 数据走微信云开发真实数据库,useMock = false,无需任何安装或配置。

若提示「不在体验成员范围内」,请联系我方,我们会把你的微信号加入体验成员名单(体验版成员上限约 15 人)。

🛠️ 方式 B:微信开发者工具导入(想自己编译调试)

  1. 下载安装 微信开发者工具(稳定版即可);
  2. 打开工具 → 「导入项目」→ 目录选择仓库根目录(即包含 project.config.json 的那一层);
  3. AppID 选择「使用测试号」,或填写 wx8c285f6257ce6c94;
  4. 若是自己的测试号且没有云环境,把 miniprogram/config.js 里的 useMock 改成 true,即可用内置 Mock 数据完整预览(发布、搜索、改状态都可以,只是数据不跨设备);
  5. 若要接入云开发:在开发者工具中开通云环境 → 把环境 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,无需任何测试框架。

六、🧪 单元测试

6.1 🛠️ 测试工具选型、学习过程与一份简易教程

我们选用的工具:Node.js 内置 assert 模块 + 自写的极简测试运行器(零第三方依赖)。

为什么不用 Jest / Mocha? 我们试过两条路:

方案 结果 原因
引入 Jest / Mocha 放弃 需要 npm install(几十 MB 依赖)、需要配置 package.json 与测试环境;助教或同学拿到仓库后必须先装 Node 依赖才能跑测试,这与「下载后双击就能验证」的交付目标冲突。
Node 内置 assert + 自写运行器 选定 一行 node test/test.js 就能跑,零安装、零配置,任何一台装了 Node 的电脑都能复现。

我们是怎么学单元测试的:

  1. 先看了阮一峰的《测试框架 Mocha 实例教程》和廖雪峰的 JavaScript 单元测试章节,弄清一个测试框架到底提供什么——用例组织、断言、结果统计,就这三件事。
  2. 于是我们自己把这三种能力实现了一遍(下面附录里就是完整实现,约 30 行),这样做有两个好处:既理解了断言库在做什么,也让项目彻底摆脱了对第三方框架的依赖。
  3. 再从「白盒测试」的方法论入手:先画出被测函数的分支结构(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 个
  ✅ 全部通过!

7fc0e31bcdfed5443f056077c84229f1

6.3 🥊 测试数据的构造思路

(1)测试数据是怎么设计出来的

我们没有随手造数据,而是从「用户真正会怎么填」倒推:

构造一份固定的样本集 sampleItems(4 条),刻意让它们在四个维度上互相区分,这样一个集合就能拉开多条分支:

_id 类型 名称 地点 状态 用途
a 寻物 校园卡 教学楼 进行中 关键词命中标题;类型过滤保留
b 招领 折叠雨伞 食堂 进行中 类型过滤时被排除
c 寻物 蓝牙耳机 图书馆 已找到 验证「终态不进公共列表」这条业务规则
d 招领 钥匙 宿舍区 进行中 关键词只出现在描述里(「小熊挂件」)→ 验证描述也参与匹配

(2)我们是怎么考虑各种情况的——三个层次

  1. 正常情况:完全合法的输入、能搜到结果的查询(用例 1、8、9)。
  2. 异常情况:空字段、非法枚举值、无匹配结果(用例 2–5、12)。这一类是「用户真的会干」的事,不是假想。
  3. 边界情况:恰好 30 字符与 31 字符(用例 6、7)、空类别字符串(用例 18)、地点别名与真实值混用(用例 15)。
  4. 业务规则层面(最容易被忽略的一层):用例 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 📸 签入记录截图

90cf2f812fc47f37b68566964e466436


GitHub 仓库 Commits 页面


0399dfc1e42071425160eed46d21cede


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

反向审查中,林诗怡实际做的四件事(都可复现):

  1. 跑测试 + 核对两端规则一致性。 在本地 cd web && node test/test.js,确认 19 个用例全绿;并逐行比对 web/js/core.js 的 filterItems / validatePublish 与小程序端 utils/api.js 的业务规则,发现「状态命名」在两端不一致(小程序端是「已通过 / 已完成」,纯函数模块是「进行中 / 已找到 / 已归还」),写进遗留问题。
  2. 用另一个微信号真机复现,而不是相信「在我机器上是好的」。 扫码预览赖彦然部署的体验版后发现:赖彦然手机上正常的图片,在林诗怡手机上不显示——直接定位到「发布时存的是本机临时路径、没上传云存储」(详见 §8 困难一)。这说明审查不只是读代码,还包括换一个身份 / 设备去复现。
  3. 审云函数 ai-recognize: 确认它做了「多模型 × 多图片格式」的依次重试,并要求把每次失败信息汇总进返回值的 attempts 字段(否则线上只看到一句「识别失败」,无从定位);同时补测了「云函数未部署 → 小程序端 wx.cloud.extend.AI 降级直连」这条兜底路径。
  4. 审后台管理端 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,在开发者工具预览时点击无效。
  • 做过哪些尝试:① 检查事件名是否写错(bindtap vs bind: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 每个可交付状态独立成版,让每一次「为什么改」有迹可循(这次的 V9 / V10 素材也因此能直接变成博客内容)。

🕳️ 遗留问题(我们明确知道、但本次没解决)

  1. 状态文案存在两套写法。 小程序端沿用了「已通过 / 已完成」的状态值,而抽出来的纯函数模块 web/js/core.js 用的是「进行中 / 已找到 / 已归还」。虽然详情页做了展示层映射(已通过 → 进行中),但同一个业务概念在两处有两种命名,是隐患:将来改一处忘另一处,就会出现「小程序显示已找到、网页显示已完成」的分裂。下一步应当统一为一套枚举,并让两边共用。
  2. 页面层没有自动化测试。 19 个用例全部集中在纯函数,发布成功后的跳转、表单回填这些交互仍然依赖人工走查(原因见 §6.3 的自我评价)。
  3. 云模式下列表是「拉取后前端过滤」。 数据量大时会变慢,应改为数据库条件查询 + 索引。
  4. 智能相机识别能力不足。依赖于平台提供的免费模型hy3,智能相机的识别能力有限,我们目前仅能做到,不撒谎————若识别不出则显示识别失败,绝不胡乱识别。

九、⏱️ 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」留一个显式的时间块,并把「跨账号验收」从测试环节提到编码环节并行做。


十、💌 评价你的队友

10.1 🌟 赖彦然评价林诗怡

值得学习的地方

  1. 核心链路是她扛下来的。 数据层 utils/api.js、发布页、搜索这几块最关键的代码(v2–v5)是林诗怡主导撰写的,接口约定清晰,我后面接页面层几乎没返工。换句话说,我能在 v6–v8 放心地做扩展和云端,是因为她把地基打稳了。
  2. 她真机扫出来一个我自己发现不了的 bug。 图片上传那次,我用自己的手机测试一切正常,是她拿自己的微信号扫体验版,才发现「换一台手机图片就不显示」。这个跨账号的问题如果只靠我自测根本抓不到,全靠她多了一台设备去复现(详见 §8 困难一)。
  3. 前端审美在线。 这次小程序的实际界面比上一次墨刀原型还顺手,卡片信息密度、空状态、分类 Tab 这些细节是她磨的,比我画的框框像样。

需要改进的地方

  1. 对于细节的打磨有时太精细 比如首页的空状态插画,她前后调了好几版,其实第一版就已经能用了。这种追求完美在 UI 上加分,但时间紧的时候要懂得先过、再迭代。

十一、🎈 写在最后

这次作业让我们真正体会到:「界面画出来」和「用户能够用起来」是两回事。 我们把上一版原型里铺开的模块砍掉了大半,反而在「发布—浏览/搜索—详情—联系—更新状态」这一条线上补出了更多的细节:发布前会拦、搜索无结果会有出口、信息被标记终态后会自动退场、想改别人的信息改不动、图片丢了还会有兜底图标。

也正是在这些地方,我们踩了坑、返了工、记下了偏差——这些写在 §8 与 §9 里的东西,可能比代码本身更有价值。


posted @ 2026-10-09 21:46  Vivian_azad  阅读(14)  评论(0)    收藏  举报