软工第二次结对作业:失物招领程序
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/fzu/202601SofwareEngineering |
|---|---|
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/fzu/202601SofwareEngineering/homework/16744 |
| 这个作业的目标 | 完成「校园失物招领」核心功能的代码实现 |
| 结对成员 | 102401531徐心铭 102401527林复彬 |
| 结对同学徐心铭的博客 | https://www.cnblogs.com/horse-head-ghost |
| 结对同学林复彬的博客 | https://www.cnblogs.com/linfubin |
| GitHub 仓库地址 | https://github.com/wu873/102401527-102401531 |
一、这次要做的东西
第一次结对作业我们用墨刀做了「校园失物招领」的原型:首页浏览、关键词搜索、发布寻物/招领、查看详情、我的发布,还设计了一个认领验证的机制。这次要把它变成真的能跑的程序。
本篇按真实开发过程写,包括中途的一次大改版。 第一版认领验证做成了"系统预设问题 + 随机抽题 + 手打答案",写完之后我们发现它在判定准确性和防试探两件事上都不成立,于是把这一块整体推翻,换成纯客观题(判断题 / 选择题)+ 限制 3 次 + 失败转人工审核。改版的来龙去脉写在 4.4 与 5.2 节,文中所有代码、截图、用例数都与当前仓库一致(262 个用例)。
任务书对这次的要求很明确:完成「发布信息 → 浏览/搜索 → 查看详情 → 联系发布者 → 更新状态」这条主线,不要求复杂后台、实名认证、即时聊天、地图定位。
我们选的是 PC 端 Web 网页,但保留移动端形态——同一套 HTML 在宽屏下是桌面网页版(顶部导航 + 右侧卡片网格 + 左侧筛选栏),在窄屏下自动变成第一次作业原型那种手机版(底部 Tab 栏)。这样助教在电脑上打开看到的是像样的网页,用手机打开也依然好用。
技术底线(这几条决定了后面所有选择)
| 约束 | 由此产生的决定 |
|---|---|
| 助教下载仓库后双击 HTML 就要能用 | 不用任何需要构建步骤的方案,不用 npm,不用脚手架 |
| 不能起本地服务器 | 不能用 ES Module(file:// 下会被 CORS 拦),不能用 fetch 读 JSON |
| 不能联网 | 第三方库必须随仓库提交,不能用 CDN |
| 没有后端 | 数据存在浏览器本地 |
二、具体分工
| 成员 | 负责内容 |
|---|---|
| 102401527林复彬 | 模型主题框架和README |
| 102401531徐心铭 | 验证防冒领功能推进,细节处理 |
| 共同 | 测试和调试,讨论方案可行性 |
三、PSP 表格
完整的 PSP 表格见仓库
docs/PSP表格.md;实际耗时明显超出预估的环节(Coding、Reporting)在第 9 节的困难里也各有对应。
| PSP2.1 | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|
| Planning 计划 | 20 | 15 |
| Analysis 需求分析 | 60 | 65 |
| Design Spec 生成设计文档 | 30 | 20 |
| Design Review 设计复审 | 20 | 25 |
| Coding Standard 代码规范 | 15 | 20 |
| Design 具体设计 | 60 | 70 |
| Coding 具体编码 | 300 | 350 |
| Code Review 代码复审 | 30 | 40 |
| Test 测试 | 40 | 30 |
| Reporting 报告 | 145 | 170 |
| 合计 | 720 | 805 |
四、解题思路与设计实现
4.1 代码实现思路
一句话概括:把「业务规则」和「界面」彻底分开,让规则能被测试,让界面只管显示。
原型阶段我们已经把功能列清楚了,但真写代码时遇到的第一件事是:哪些东西必须做对,做错了后果严重?
想了一圈,有四个:
- 验证题的正确答案不能泄露——这是防冒领机制的根基,一旦答案能从列表页、详情页或答题页的数据里扒出来,整个设计就废了;
- 状态流转不能乱——东西已经还回去了,信息还挂着「待认领」,别人就会白跑一趟,这正是需求里要解决的痛点;
- 只有发布者能改自己的信息——没有账号体系的前提下,这条得靠本机身份标识来兜;
- 搜索得搜得到——搜「校园卡」找不到「校园卡一张(学号 2023****)」,用户就不用了。
这四件事全都是"输入 → 输出"的逻辑,跟界面上长什么样没关系。所以我们的做法是:把它们全部抽到一个数据层 js/store.js 里,界面层只负责调方法和画界面。
这样安排之后,一个额外的好处出现了:这些规则可以被单元测试完整覆盖,而且不需要浏览器环境。
实现上最关键的一步在这里——数据层的存储是注入进去的。createStore(storage) 只要求传进来的对象有 getItem / setItem / removeItem 三个方法(和 localStorage 一模一样),于是浏览器里传 localStorage、测试里传内存实现,业务代码一个字都不用改:
| 运行环境 | 注入的东西 | 结果 |
|---|---|---|
| 浏览器 | LF.createStore(window.localStorage) |
数据落盘,刷新还在 |
| 单元测试 | LF.createStore(LF.createMemoryAdapter()) |
每个用例一个干净的内存仓库,互不干扰 |
完整签名与实现见
js/store.js的LF.createStore。
测试因此不需要 jsdom、不需要起服务器、不需要装 Node.js——双击 test/test.html 就能跑。
整体分层:
页面 HTML 只管结构
↓
page-*.js 收集输入、调数据层、渲染结果
↓
store.js ★ 全部业务规则(校验 / 搜索 / 状态流转 / 认领比对)
↓
utils.js 纯函数:时间格式化、文本归一化、图片压缩
↓
存储适配器 localStorage → sessionStorage → 内存,依次降级
4.2 数据流图

图中最重要的一条是底部那句:数据层向上返回的每一个对象都经过 toPublic() 处理,每道题的正确答案(answer 下标)在这一步就被剥离,认领记录与申诉明细同样不外传。 首页、搜索结果、详情页、答题页拿到的数据里根本不存在正确答案——不是靠界面藏起来,而是数据压根没传出来。
4.3 一个差点漏掉的收尾:把公开特征"一刀切"之后,得告诉失主该搜什么
防泄漏那一刀(非「其他」分类只露出定死的 1–2 项特征、取消自由描述)砍下去之后,我们以为事情就完了,其实只做了一半:信息面收窄的代价,最后是失主承担的。 而且这一节还留了个尾巴——有一类物品根本没法"露特征",得单独处理,见下一节。
失主的习惯是搜描述里的词——「蓝色充电宝」「伞柄有划痕」。这些词在收窄之后已经不存在于公开面里了(描述框被数据层强制置空),于是搜出来 0 条。而"0 条结果"和"没人捡到"在页面上长得一模一样,他不会怀疑自己搜错了,只会认为东西没人捡到,然后放弃。这才是这一刀真正的副作用:不是信息少了,而是用户无从知道信息少了。
所以每个分类都要自己交代清楚。分类字典里加一个 searchHint,首页选中分类时显示在筛选区旁(手机端在筛选条下面):
| 分类 | 页面上的引导语 |
|---|---|
| 证件卡片 | 🔍 本类只公开卡号:请按证件号搜索(记不全的位用 * 顶位,位数要和卡号一样长),姓名、院系、卡号之外的细节都不公开。(卡号(打码))(就是全部可搜的公开特征) |
| 电子产品 | 🔍 本类只公开品牌和型号:请按品牌或型号搜索,颜色、外观、保护壳都不在公开信息里。(品牌、型号)(就是全部可搜的公开特征) |
| 耳机 | 🔍 本类只公开品牌和颜色:请按品牌或颜色搜索,充电盒、外观磨损这类细节不公开。 |
| 雨伞 | 🔍 本类只公开伞面颜色和柄型:请按颜色或柄型搜索,图案、划痕这类细节不公开。 |
| …… | 有特征的分类各一条,写法同上 |
| 其他 | 🔍 这一类不好指定公开特征,也不出认领验证题:描述和照片直接公开,请翻列表(或用描述里的词、按地点)找,翻到了直接联系发布者核对。 |
三个决定值得记一笔:
- 不是"请搜 X",而是"请搜 X,Y 搜不到"。 只告诉失主搜得动的字段,他不知道"颜色"根本不在公开面里,照样会去搜。所以每条引导语都带一句"哪些细节不公开"。
- 显不显示由字典决定,不维护第二份名单。
LF.searchHintFor()只读LF.CATEGORIES[].searchHint这一份数据:写了就显示、没写就返回空串由页面隐藏。所以脏数据里的未知分类天然没有引导,页面不用维护分类名单。 - 引导语自己会"认错"。 特征名和引导语存在两个地方,最容易出的岔子是改了字段名忘了改文案。所以
LF.searchHintFor()会检查searchHint里有没有提到字典里的字段名,没提到就把它附在后面自曝其短;validate.spec.js里还有一组成用例直接断言"每个特征名都必须出现在引导语里"("搜索引导:每个分类都要告诉失主该搜什么"一节)。
原有的特征筛选按钮一个没删。 引导语讲的是"关键词该怎么搜",而侧栏那组按品牌/颜色的筛选按钮本身就能用(点「华为」直接筛出华为耳机),两者互补:会搜的用搜索框,不会搜的点按钮。这一条是刻意保留的取舍。
图 2:首页选中「证件卡片」时,侧栏出现这条引导。引导语的位置就在特征筛选下面,失主不会错过。

图 3:同一个首页,选中「其他」时引导语换成了另一件事——"没有特征可筛,翻列表找"。

4.3.1 补上对照组:「其他」类不出验证题
把上面那套"锁定特征 + 出题验证"推到底,就会撞到一个绕不开的分类:「其他」。它之所以是「其他」,正是因为这一类物品没有客观、能锁死的细节——一本书、一串钥匙、一个说不清型号的充电器。我们对它做过一个自问:这一类该出什么题? 答案是:出题人只能把描述里的细节再抄一遍("扉页上写着谁的名字"配上描述里的"扉页有名字"),同一份信息写两次,而且冒领者照着公开描述就能选对——在一类根本没有可锁特征的信息上强制出题,只会逼人编题。
所以这一类改成对照组,走信任原则:不设认领验证题,描述和照片直接公开,联系方式直接可见,见到的人直接联系发布者核对。
| 分类 | LF.allowVerifyFor() |
出题 | 谁看得到联系方式 |
|---|---|---|---|
| 有公开特征的八类 | true |
必须出 3–5 题 | 答对全部题目的人 |
| 其他 | false |
不出题 | 所有人(公开) |
| 字典里没有的分类(脏数据) | null |
不校验 | 分类字段自己已经报错,不再叠加 |
四个实现上的决定,都是被具体问题逼出来的:
- 判断收在一个函数里,页面不写分类名。 依据是"这个分类有没有公开特征定义"(
LF.featuresFor),不是手写category === 'other':将来真出现第二个"不出题"的分类,只改这一处。返回三态而不是布尔是为了第三行的脏数据:分类压根不在字典里时,"该不该出题"无从判断,返回null让校验跳过,免得在"分类错误"之上再叠一条用户改不掉、也无从理解的"请至少出 3 道验证题"。 - 数据层说了才算,前端藏了不算数。
store.create/store.update对「其他」强制把questions置空(和"描述按分类置空"是同一个套路),前端就算传了题目也落不了盘;needsVerify()对「其他」恒为 false,认领入口startClaim()直接回"这条信息不需要验证"。 - 旧数据必须就地清掉(
DATA_VERSION3 → 4)。 演示数据seed_6原本给这一类挂着 4 道题,v3 时代的真实数据也可能有。如果只改新流程,这些老记录会继续按老规矩要求认领者答题,而发布页已经不再提供题目编辑入口——发布者连"把这套题删掉"都做不到,等于被永久锁在旧流程里。所以迁移把它们清空、作答次数一并复位。 - 页面要给"去处",不能只是把出题区一藏了事。 发布者点开一个空白的出题区会以为功能坏了。所以选中「其他」时,出题区换成一整块说明:为什么不出题、描述和照片会直接公开、代价是没有防冒领闸门(所以别把唯一凭据——"书里夹着一张写名字的借书凭条"——写进公开描述)。详情页、我的发布、发布成功页也各说一句,免得认领人以为页面坏了、发布者以为自己漏填了。
这条改动是有代价的,我们把它写进了「已知限制」:这一类没有防冒领闸门,理论上谁先看到都能联系发布者。换来的是这个分类终于有一个说得通的流程,而不是一个逼着人编题的流程。
图 4:发布页选中「其他」——出题区整块消失,换成信任模式说明(含代价提醒);图 5 是切回「电子产品」的样子,出题区立刻回来。


图 6:这一类招领的详情页。没有验证题,联系方式直接可见,页面主动说明"为什么不需要答题"。

图 7:「我的发布」里,「其他」类条目显示的是信任模式说明,而不是「验证题 0 道」这种让人以为漏填了的字样。

4.4 关键实现的流程图:认领验证(含一次大改版)
这是本项目最核心的一段逻辑,也是第一次作业原型里最有价值的原创点。
要解决的问题很具体:招领信息如果把联系方式直接公开,任何人都能看到,真正的主人反而可能被抢先联系。 但完全不给联系方式,又没法线下交接。
第一版(原型方案):发布招领信息时按物品分类设置 2–3 项只有物主才知道的特征(证件卡片问卡面姓名 / 卡号后四位 / 卡面标记,雨伞问伞面颜色 / 伞柄特征……),认领时随机抽 2 项,让认领人手打答案,全部答对才解锁联系方式。
做完自测之后,我们对着评审意见把这条路重走了一遍,发现两个绕不开的毛病:
| 毛病 | 具体表现 | 后果 |
|---|---|---|
| 判不准 | 发布者写「深蓝色」,失主记得是「藏青」;发布者写「蓝色小熊贴纸」,失主写「小熊贴纸」 | 真失主被拦在外面。为了宽容就得做文本归一化,而"宽容到什么程度"本身没有正确答案 |
| 能试探 | 为了让失主知道错在哪,页面必须指出答错的题(否则他无从下手) | 每答错一次就排除一个选项,3 次机会足够把答案试出来,限制次数形同虚设 |
第二版(本仓库采用的方案):纯客观题 + 限制次数。 把判定从"文本比对"换成"选项比对",从根上消掉这两个问题:
- 拾得者出题,只允许判断题和选择题,题量 3–5 题(建议 4 题),选择题 2–4 个选项并指定正确答案;系统再按物品分类给出题模板,降低出题门槛;
- 认领者一次性答完全部题目再提交,每题只能点选项,没有自由文本;
- 系统统一判定,只回"通过 / 不通过":未通过时提示语统一为「回答的细节与描述不符」,不告诉他是哪一题错——没有排除法的线索,3 次机会就真的只剩 3 次;
- 最多答 3 次,答对不消耗次数;发布者换了题目则重新给满 3 次;
- 3 次全败后解锁【申诉】,认领者写下只有物主才知道的细节和联系方式,进入人工审核通道,由拾得者判断是否交还。

图里有四个地方是特意设计的:
- 题型只有判断和选择:把"答对答错"变成下标比较,没有措辞歧义,也删掉了整块文本归一化逻辑(
U.normalizeAnswer()随之删除); - 一次性作答 + 统一判定:提交一次就判全部题目,页面拿不到任何"哪题错"的信息,改前端也造不出来;
- 次数上限写进数据层:
attemptsLeft存在信息上,扣减只发生在submitClaim()内部,绕过界面直接调数据层也躲不开; - 3 次用完才解锁申诉:
canAppeal由"剩余次数是否为 0"推导,认领页、结果页、详情页三处按钮都以它为准,不会出现"提前出现申诉入口"。
图 13:这一版答题页长这样——一次性看到全部题目、一次答完,没有自由文本,也没有"上一题/下一题"的分页(分页反而会让人以为可以回头改)。

4.5 关键代码片段
只贴"看这一眼就明白设计"的几行。完整实现见对应文件,行号以当前仓库为准。
片段一:toPublic() —— 防冒领的第一道闸门
// js/store.js — toPublic()
out.questions = questions.map(publicQuestion); // ← 只有题干和选项
delete out.claims; // 认领记录(谁选了什么)只给发布者
delete out.appeals; // 申诉明细(姓名、联系方式)更不外传
if (out.locked) out.contactWay = ''; // 未解锁:联系方式根本不进页面
out.contactName = isOwner ? post.contactName : maskName(post.contactName); // 张**
为什么这样写: 注意这里是 delete out.claims / delete out.appeals,而不是"界面上不显示"。区别很关键——如果只是前端 display:none,答案、认领记录、申诉人的联系方式仍然在内存对象里,打开控制台就能看到。把它们从数据里删掉,才是真的拿不到。
姓名打码同理:contactName 对非发布者返回 张**,完整姓名只给发布者本人。
这几条规则有专门的测试用例直接验证(test/specs/privacy.spec.js):拿到公开视图后断言题目对象没有 answer 属性、整个 JSON 序列化结果里搜不到 answer 字样、hidden / claims / appeals 三个字段压根不存在。测试断言的是"数据里没有",不是"界面没显示"——这样哪天有人为了"提升体验"把它加回来,用例会立刻变红。
片段二:统一判定 —— 只回"通过 / 不通过",不回"哪题错"
// js/store.js — submitClaim() 的失败分支
if (!passed) {
// ★ 只回一句统一提示,绝不回传"哪一题错了"
return {
ok: true, passed: false, remaining: left - 1,
locked: left - 1 <= 0, canAppeal: left - 1 <= 0,
message: '回答的细节与描述不符'
};
}
两个刻意的取舍:
-
判定是"全部答对",而且不告诉认领者错在哪一题。 第一版为了让失主知道错在哪,必须回传
failed: [{ q, input }];这一版把它彻底删掉了——一旦告诉了他,3 次机会就变成"每次排除一题"的试探过程。代价是失主可能觉得"为什么不说清楚",所以结果页把理由写明了("否则反复试几次就能把答案试出来"),并给出申诉出口。记录里的correct字段只在listClaims()里返回,而那个接口有发布者校验。 -
比对不再需要文本归一化。 第一版有一套
U.normalizeAnswer()(去空格、全角转半角、忽略标点)来兜住「王小明 / 王 小明 / 王小明。」的差异。改成客观题之后,比对的对象是两个整数下标,这块逻辑连同它的映射规则一起删掉了——少一个能出错的规则,就少一类误判。而"为了比较而做的归一化"和"为了保存而做的清理"必须分开这条经验,仍然保留在搜索模块里(U.normalizeText()只用于搜索匹配,U.clean()才用于存储)。
片段三:搜索 —— 一个纯函数
// js/store.js — matchKeyword()
var terms = U.normalizeText(keyword).split(' ').filter(Boolean); // 多词按"与"
var haystack = U.normalizeText([title, description, location, 分类名, 区域名, 类型名,
maskName(contactName)].concat(featureValues(post)).join(' ')); // 公开特征的值也要能搜到
return terms.every(function (t) { return haystack.indexOf(t) !== -1; });
几个考虑:
- 用
indexOf而不是正则。 用户输入什么就搜什么,如果拼进new RegExp解析,输入一个(就会让整个页面崩掉。 - 多关键词按「与」处理。 搜「耳机 图书馆」只返回同时在标题/描述/地点里提到这两者的信息。
- 搜索范围包含分类名和地点名。 所以搜「证件卡片」能找到标题写「校园卡」的信息,搜「食堂」能找到地点写「第二食堂门口」的信息。
- 归一化里加了 NFKC 和零宽字符过滤。 从微信、QQ 里复制过来的文字经常夹着看不见的零宽字符,人眼看着一样但字符串比较不相等。
片段四:存储降级链
LF.createBrowserAdapter() 按 localStorage → sessionStorage → 内存 的顺序探测并降级,每一步都真写一次再删掉来确认可用(probeStorage(),因为无痕模式或企业策略下连读 localStorage 属性都可能抛异常)。
为什么中间要夹一层 sessionStorage: 这是个多页应用,每跳一次页面,内存里的变量全部重置。如果 localStorage 不可用就直接退回内存,「发布 → 跳成功页」这一步数据就没了,主流程当场断掉。sessionStorage 至少能让同一标签页内把流程走完——降级的目标是"功能仍然可用",不是"程序不崩"这么低的标准。
页面顶部会同步出现一条提示条,明确告诉用户当前数据的保留范围,而不是让他在毫不知情的情况下丢数据。
五、附加特点设计与展示
5.1 创意独到之处及意义
核心特点:客观题认领验证 + 限制次数 + 人工审核兜底,把"谁有资格看到联系方式"从口头约定变成程序判断。
意义在哪里?我们回看第一次作业梳理的痛点:信息分散、易被淹没、查不到进度。但真正下手做的时候发现还有一个更尖锐的问题:
招领信息挂出去,联系方式给谁?
- 全公开 → 任何人都能看到,冒领几乎没有成本。真失主还没看到消息,东西可能就被别人领走了;
- 完全不公开 → 失主联系不上拾得者,信息等于白挂。
这是个两难。我们的解法是引入一个只有物主能通过的门槛:拾到东西的人出几道只有物主答得对的客观题,认领人全部答对才解锁联系方式。
这个设计的价值有三层:
- 对拾得者:敢把信息挂出来了,因为不怕被冒领;
- 对失主:能证明"这东西确实是我的",反而更容易拿回来;
- 对平台:信息可信度提高,别人也愿意用。
而且它顺带解决了一个隐私问题:发布者的联系方式默认不公开,只有通过验证的人能看到,减少了联系方式被爬走的风险。
另外三个附加特点(任务书里点名提到的那两类我们都做了):
| 特点 | 说明 |
|---|---|
| 一键复制联系方式 | 详情页和验证通过页都有,点一下进剪贴板。复制失败时不是只弹句错误就完了,会自动弹出可手动复制的输入框,事情还能办成 |
| 按物品分类 / 地点筛选 | 首页支持分类、地点、状态三个维度的筛选,选项右侧实时显示条数;桌面用左侧栏,手机用图标条 + 下拉 |
| 搜索历史 + 热门搜索 | 最近 10 条历史,去重、可单条删除、可一键清空;热门词由真实数据统计得出,不是写死的 |
还有一个不在清单里、但我们觉得挺有用的细节:首次打开自动灌入一批演示数据,其中两条归属于使用者本人。这样点进「我的发布」不是一片空白,可以立刻演示"标记已归还""查看认领申请"这些需要发布者身份才能用的功能。
5.2 实现思路
认领验证的数据结构:
{
id: 'found_xxx',
type: 'found',
questions: [ // ★ answer 永远不出现在公开视图里
{ id: 'q1', type: 'judge', stem: '卡面上写的是王小明这个名字',
options: ['正确', '错误'], answer: 0 },
{ id: 'q2', type: 'choice', stem: '卡号后四位是',
options: ['3882', '1027', '5566'], answer: 0 },
{ id: 'q3', type: 'choice', stem: '这张卡属于哪个年级',
options: ['2023 级', '2022 级'], answer: 1 }
],
claims: [ // 收到的认领记录(只有发布者能看)
{ at: 1759..., passed: true, voucher: 'CL-2026-3882', answers: [...] },
{ at: 1759..., passed: false, answers: [...] }
],
appeals: [ // 3 次全败后的人工审核申请(明细只有发布者能看)
{ id: 'appeal_xxx', claimantId: 'me_xxx', name: '李思远',
contact: '微信:lisiyuan2022', detail: '卡号后四位 3882,卡套里还有一张借书凭条……',
decision: 'pending', note: '', voucher: '', decidedAt: null }
],
attemptsLeft: 2
}
四个关键设计点:
-
题目和答案存在一起,但下发的对象里没有答案。
toPublic()用publicQuestion()重新造一份只含{ id, type, typeName, stem, options }的题目数组;answer只活在两个地方:数据层内部判定时,以及发布者编辑时的getEditable()。 -
题型只有判断题和选择题,且结构由数据层兜底。 判断题的选项固定为「正确 / 错误」(发布者传什么都不算数);选择题里的空选项行会被丢掉、正确答案下标跟着重排,所以前端多传一行空选项也不会把答案对错位。
-
申诉是一次落库的表单,不是一句弹窗文案。 只做一个"请线下联系发布者"的弹窗最省事,但发布者就没有可判断的凭据;所以申诉内容(称呼、联系方式、物品细节)存在信息上,发布者在「我的发布 → 人工审核」里逐条处理:同意交还即解锁联系方式并生成线下交接编号,驳回则附一句理由回给申诉人。
-
申诉的解锁条件是"剩余次数为 0"。
canAppeal由attemptsLeft <= 0推导(见js/store.js的toPublic()),认领页、结果页、详情页三处按钮都以它为准,不存在"次数没用完就出现申诉入口"的路径。
5.3 代码片段
每个片段只留"看这一眼就明白"的几行,完整实现见
js/store.js/js/config.js。
出题:把题目的结构固定下来
题目结构由数据层兜底:判断题的选项固定是 ['正确', '错误'],发布者传什么都不算数;选择题里的空选项行会被丢掉、正确答案下标跟着重排。规则写成一个常量表(LF.VERIFY:3–5 题、2–4 个选项、最多答 3 次),界面上的提示语也从这里取,不写死数字。
为什么由数据层重排下标: 界面上"删除一个选项""切换题型"都会改动选项数组,如果直接在页面上算正确答案的下标,很容易出现"看着选的是第 2 项,存进去却指到第 3 项"。把重排规则放在数据层并配单元测试,这类错位就只有一个地方可能出错。
统一判定:把"哪题错了"彻底留在数据层
失败结果统一是 { ok: true, passed: false, remaining, canAppeal, locked, message: '回答的细节与描述不符' }。记录里带上了每题的对错,但它只从 listClaims() 返回,而那个方法做了发布者校验——发布者看到的才是完整明细;认领者拿到的失败结果里连 failed 字段都没有。测试里直接断言"全错和只错一题返回的对象结构完全一样",防止哪天有人顺手把错题信息加回去。
图 9:验证未通过的样子——只给一句统一提示与剩余次数,不显示哪题错。

图 10:验证通过——生成认领凭证码并解锁联系方式,可一键复制。

图 11:3 次用完之后,【提交申诉】按钮才出现。

时间的可注入性:把不确定性关进盒子里
createStore(storage, options) 允许传入固定的 now(测试夹具里固定成 2026-10-02T10:00:00)。时间相关的逻辑必须能在测试里固定,否则测试会间歇性地红。第一版还额外注入了随机源(因为要随机抽题),改成"一次答完全部题目"之后,随机性从这条链路里彻底消失,注入的参数也就只剩 now——能删掉的不确定性就别留着。涉及"3 次失败后锁定"的用例,就老老实实失败三次再断言,而不是想办法"快进"。
状态流转:把"重复点也没事"做进数据层
markDone() 是幂等的:第二次调用返回 unchanged: true,并且不改动原有的完成时间——否则"已归还于今天"会变成"已归还于刚才",记录就不准了。归属校验(post.ownerId !== actor 直接拒绝)也在这一个函数里,不靠按钮是否显示。
5.4 实现成果展示
以下截图都是当前仓库的真实运行画面,编号与正文各处的引用一致。
桌面端首页:左侧筛选栏(分类 / 地点 / 状态,各选项带实时条数),右侧卡片网格,卡片直接标出寻物/招领与当前状态;选中分类时出现搜索引导。

搜索结果:关键词命中标题、描述、地点、分类名;多关键词按「与」匹配。

详情页(联系方式锁定):发布人姓名打码,联系方式显示为「通过认领验证后可见」,下方说明"出了几道验证题、最多答 3 次、3 次没过可以申诉"。

详情页(「其他」类,走信任原则):没有验证题,联系方式直接可见,页面主动说明原因。

发布页:寻物与招领共用一套表单;招领额外有出题区,可以加判断题 / 选择题、切换题型、增删选项并指定正确答案,也能按物品分类插入常用模板。

发布页(「其他」类):出题区换成信任模式说明。

我的发布:状态维护入口、验证题数与认领 / 人工审核统计、本地数据管理。

认领验证(答题页):一次性看到全部题目——判断题点「正确 / 错误」,选择题点选项;顶部显示剩余作答次数,底部实时提示还差几题没选。

申诉(人工审核通道):认领者填写的申诉表单,提交后由发布者在「我的发布」里逐条处理。

手机端(宽屏拉窄自动切换):

单元测试报告:262 个用例全部通过。

六、目录说明和使用说明
完整版见仓库
README.md与docs/目录说明与使用说明.md,这里给出要点。
6.1 目录是怎么组织的
按「页面 → 样式 → 逻辑 → 测试 → 文档」五层分开,核心思路是页面文件只管结构,业务规则全部收在数据层:
校园失物招领Web/
├── ① 页面层:9 个 HTML,文件名即用途
│ index / search / detail / publish / success / mine / verify / verify-result / appeal
├── ② 样式层:3 个 CSS
│ css/tokens.css 设计令牌(色板、圆角、阴影、字号)
│ css/components.css 通用组件(卡片、标签、按钮、表单、题目编辑器、弹层、空状态)
│ css/layout.css 响应式布局(900px 断点)
├── ③ 逻辑层
│ js/config.js 业务字典(类型、分类与搜索引导、地点、出题模板、验证规则)
│ js/utils.js 纯函数工具(时间、文本归一化、图片压缩、剪贴板)
│ js/store.js ★ 数据层:全部业务规则(含申诉通道与旧数据迁移)
│ js/seed.js 演示数据(招领信息自带 3–4 道客观题)
│ js/ui.js 界面公共层(导航外壳、卡片渲染、轻提示、弹窗、人工审核弹窗)
│ js/page-*.js 9 个页面的控制器,与 HTML 一一对应
├── ④ 测试层
│ test/test.html 测试入口,双击即跑
│ test/specs/*.spec.js 用例(7 个文件,262 个用例)
│ test/lib/ Mocha 与 Chai 浏览器构建(随仓库提交)
└── ⑤ 文档层
docs/ 目录说明、PSP、博客草稿、流程图与截图
命名约定:
- 页面:小写、多词用连字符(
verify-result.html); - 脚本:
page-xxx.js一定是某个 HTML 的控制器,与页面同名,一眼能对上;不带page-前缀的是被复用的公共模块; - 数据字段:一律小驼峰,语义直白(
happenedAt、contactWay、doneType);字典类字段存key而不是中文,中文只出现在config.js里。
6.2 测试人员如何运行
- 把
校园失物招领Web文件夹整个下载到本地; - 用谷歌浏览器打开根目录下的
index.html(双击即可)。
不需要安装任何东西,不需要起服务器,不需要联网。第一次打开会自动灌入演示数据。
运行单元测试:双击 test/test.html,用 Chrome 打开,自动跑完全部 262 个用例。
务必用 Chrome。
file://协议下不同浏览器对本地存储的处理不一样:Chrome 把所有本地文件视为同一来源,页面之间能共享数据;Firefox 会把每个文件当成独立来源,导致「发布成功后跳到成功页,数据却读不到」。本次作业统一指定 Chrome。
建议的验收路线(走一遍就覆盖了全部核心功能):
| 步骤 | 操作 | 应该看到 |
|---|---|---|
| 1 | 首页点分类图标、切换寻物/招领 | 列表随之筛选,标题与条数同步变化;选中分类后出现一条搜索引导,写明这一类只公开哪几项特征、该按什么搜|选「其他」时换成"翻列表、按地点找" |
| 2 | 搜索「校园卡」 | 出现结果;搜不到时给出空状态和下一步建议 |
| 3 | 打开一条「其他」类的招领信息(演示数据 seed_6《高等数学》) |
详情页写着这一类是信任原则:没有验证题,描述、照片和联系方式都直接可见 |
| 4 | 打开一条带 🔒 需验证的招领信息 | 联系方式显示「通过认领验证后可见」;题目只有题干和选项,看不到正确答案 |
| 5 | 点「我要认领」,一次选完全部题目但故意选错一题 | 跳到「验证未通过」,只提示「回答的细节与描述不符」与剩余次数,不显示哪题错 |
| 6 | 再答一次,这次全部选对 | 跳到「验证通过」,给出凭证码与解锁的联系方式 |
| 7 | 点「+ 发布信息」,类型选「招领」、分类选「其他」 | 出题区消失,换成一段说明:这一类不出验证题、描述会直接公开、代价是没有防冒领闸门 |
| 8 | 同一页把分类改成「电子产品」 | 出题区立刻回来,可加判断题 / 选择题、切题型、指定正确答案;题数不足 3 题时字段标红 |
| 9 | 发布成功 →「查看我的发布」 | 新信息出现在「进行中」,并显示「验证题 N 道」;「其他」类的条目则显示"不设认领验证题" |
| 10 | 点「标记已归还」并确认 | 条目移到「已完成」;回首页看到它变成「已归还」 |
| 11 | 找一条已经答满 3 次的信息(演示数据 seed_mine_1 已经答满) |
详情页底部按钮变成「📮 提交申诉(人工审核)」 |
| 12 | 填申诉表并提交 → 回「我的发布」点「人工审核 1 待处理」 | 看到对方写的细节与联系方式,点「同意交还」后对方即可看到你的联系方式 |
想知道某条演示信息的正确答案:在「我的发布」里点它的「编辑」,题目编辑器里被圆点选中的那一项就是正确答案(seed_mine_1 的四道题答案依次是:正确、正确、3882、2023 级)。
演示前想恢复初始状态:在「我的发布」页底部「本地数据」区域点「恢复演示数据」。
七、单元测试
7.1 选用的测试工具与学习过程
选用 Mocha + Chai。
学习路径是这样的:先看了任务书附录里推荐的几篇(廖雪峰的 JavaScript 教程、阮一峰的 Mocha 实例教程),照着把官方文档的 Getting Started 跑了一遍,理解了 describe / it / beforeEach 这套 BDD 风格的组织方式,然后决定用它。
遇到的第一个问题不是怎么写测试,而是"在哪跑"。 按常规做法是 npm install mocha,在 Node 里跑——但这个项目本身不需要 Node,如果为了跑测试让助教先装 Node,就违背了"下载后双击就能用"的底线。
于是改成把 Mocha 的浏览器版构建随仓库一起提交,test/test.html 用 <script> 标签按顺序加载 lib/mocha.js、lib/chai.js、被测代码、各 spec,最后 mocha.run();跑完把结果写到 <html data-selftest="PASS|passing=262|failing=0"> 上,命令行也能检查。这样双击 test/test.html 就能跑,离线可用。
一个版本上的坑:Chai 从 5.x 起只提供 ES Module,普通 <script> 加载会报 SyntaxError: Unexpected token 'export',而 file:// 下又不能用 type="module"(会被 CORS 拦)。所以 Chai 固定在 4.5.0——最后一个带 UMD 浏览器构建的版本。仓库里 test/lib/_fetch-libs.py 记录了这两个库的来源和版本,需要时可以重新下载。
图 16:双击 test/test.html 的测试报告。

7.2 一份简易教程:从零开始给这个项目加一条测试
- 第一步:想清楚要测什么。 不是"测这个函数",而是"什么情况会出错"。比如发布表单,要测的不是
validatePost能返回结果,而是"漏填物品名称时会不会拦住"。 - 第二步:造一份必然合法的输入作为基准。
test/specs/helpers.js里放了validFound()/validLost()两份基准数据(含一套标准验证题和一份标准作答),每个用例只在这份基准上改一个字段。 - 第三步:每次只改一个字段,测它的边界。 例如把
title改成'卡'(1 个字)断言报title错,改成'校园'(2 个字)断言不报——下边界和边界内侧成对写。 - 第四步:把当前时间固定下来。 不固定
now,涉及"相对时间显示""挂了多久该提醒"的断言会随运行时刻改变,测试就会间歇性地红。(第一版还要固定随机源,因为那时是随机抽题;改成"一次答完全部题目"之后,随机性从这条链路里消失,注入的参数只剩now。) - 第五步:用内存存储隔离每个用例。 每个
it里都调T.makeStore()新建一个 store(注入LF.createMemoryAdapter()),用例之间互不污染,也不用担心测试数据残留。
上面每一步对应的真实代码都在
test/specs/helpers.js与test/specs/*.spec.js里,这里不再整段照抄。
7.3 测试代码展示
一共 7 个测试文件、262 个用例,按被测模块划分:
| 文件 | 测试对象 | 用例数 | 重点 |
|---|---|---|---|
validate.spec.js |
LF.validatePost |
77 | 每个字段的上下边界、出题规则(题量 3–5、选项 2–4、必须指定正确答案、选项之间要差别明显)、公开特征必填与取值合法性、卡号打码位数、"出题禁区"不变式、搜索引导语与特征字典的一致性、「其他」类不出验证题、老记录的状态流转 |
query.spec.js |
matchKeyword / queryPosts / 搜索历史 |
57 | 命中与不命中成对写、排序、多关键词、公开特征的搜索与筛选、打码卡号的通配搜索(位数一致 + 双向 *) |
privacy.spec.js |
toPublic / markDone / 权限 |
36 | 正确答案不外泄、认领记录与申诉不外泄、状态流转、越权拦截 |
verify.spec.js |
startClaim / submitClaim |
32 | 一次性取题、统一判定、次数与申诉解锁、换题重算次数、「其他」类不开放认领入口 |
appeal.spec.js |
submitAppeal / listAppeals / resolveAppeal / 迁移 |
28 | 解锁条件、表单校验、发布者处理、申诉隐私、v1 → v2 迁移 |
storage.spec.js |
适配器与容错 | 27 | 脏数据、配额错误、首次灌数据、演示数据的公开特征完整性 |
e2e.spec.js |
主流程串联 | 5 | 模块拼起来也是通的(含"3 次全败 → 申诉 → 同意交还"链路) |
| 合计 | 262 |
这里挑两段最能说明问题的(完整用例见 test/specs/verify.spec.js 与 test/specs/e2e.spec.js):
① 统一判定:全错和只错一题,返回给前端的东西必须一模一样
同一个发布者的信息,两次提交分别"只错一题"和"全错",然后断言两个返回对象的键集合完全相同、message 都是「回答的细节与描述不符」,而且序列化后连题号(q3)都不出现。为什么费劲去断言"结构一样": 因为这是本次改版的核心承诺。如果哪天有人为了"提升体验"把 failed 加回来,这条用例会立刻变红——把设计决策写成测试,它才不会被后续改动悄悄推翻。
② 端到端串联:模块拼起来也是通的
e2e.spec.js 用一个用例走完主流程:发布招领并出 4 道题 → 关键词搜到 → 打开详情(看到描述、看不到联系方式与 answer)→ 先答错一题(扣一次次数、只回统一提示)→ 再全对(拿到 CL- 开头的凭证码)→ 再看详情不再锁定 → 标记已归还并在列表/搜索里显示「已归还」。单模块测试都过、拼起来却出问题,是结对编程里很常见的情况——所以专门写了一个文件把主流程串一遍。
7.4 构造测试数据的思路
思路一:等价类划分 + 边界值。
每个字段都同时测"缺失/过短"和"超长"两侧,并且卡在边界的两边各测一次。比如物品名称限定 2–40 字,就测 1 字(应报错)、2 字(应通过)、40 字(应通过)、41 字(应报错)。
思路二:每次只动一个变量。
基准数据永远是一份"必然合法"的输入,每个用例只改其中一个字段。这样一旦失败,出错原因就是唯一的那个字段,不用排查。
思路三:正例反例成对写。
只测"应该通过"会写出过于宽松的实现,只测"应该拒绝"会写出过于严格的实现。搜索、答案比对、权限校验这三处全部是成对写的。
思路四:把不确定性消灭掉。
固定当前时间 2026-10-02T10:00:00。涉及"3 次失败后锁定"的用例,就老老实实失败三次再断言,而不是想办法"快进";第一版还需要固定随机源(那时是随机抽题),改成"一次答完全部题目"之后,随机性从这条链路里消失,注入的参数只剩时间。
如何考虑将来测试人员的刁难?
我们自己先扮演了一遍"刁难的测试人员",专门想了几类:
| 刁难思路 | 我们的应对(对应用例) |
|---|---|
| 前端藏起来了,数据里还在吧? | 断言 JSON.stringify(view) 里搜不到 answer,并且断言 claims / appeals 根本不在公开对象里 |
| 前端传了 id 和浏览量,能覆盖原值吗? | 恶意构造 update(id, { id: '伪造', views: 9999, ownerId: '黑客' }),断言这些字段不被篡改 |
| 别人能不能改我的信息? | 用另一个 actor 调 markDone / update / remove / listClaims / listAppeals,断言全部被拒 |
| 从返回值里能看出哪题错了吗? | 断言失败结果里没有 failed 字段、序列化后连题号都不出现,并且"全错"与"只错一题"的返回结构完全一致 |
| 次数没用完就想过申诉? | 断言 attemptsLeft > 0 时 submitAppeal 被拒,只有答满 3 次才允许 |
| 一次不选完就提交? | 断言返回"还有 N 道题没有作答",且不消耗次数(attemptsLeft 不变) |
| 存储里塞坏数据会白屏吗? | 写入非法 JSON、非数组、混入 null / 字符串 / 缺 id 的对象,断言页面仍能正常工作 |
| 存储写满会怎样? | 用会抛 QuotaExceededError 的假适配器,断言返回的是中文的可操作提示而不是异常 |
搜索框输入 ( 会不会崩? |
用 indexOf 而不是正则,天然免疫 |
| 空关键词、纯空格、全角空格? | 断言不过滤(返回全部)而不是返回空列表 |
| 旧版本的数据打开会不会崩? | 造一条 v1 的「隐藏特征」数据,断言迁移后能正常显示、旧答案没有残留在数据里,并且重新出题后验证能重新生效 |
为什么这么在意这些: 因为这几类恰恰是"实现的时候最容易忽略、出问题后果又最严重"的地方。特别是第一条——如果 toPublic 哪天被改坏了,界面上看不出任何异常,但防冒领机制已经形同虚设。给这种"坏了也看不出来"的逻辑配上测试,才是测试真正的价值。
7.5 一个反面教材:测试全绿,但搜索框根本看不见
上面写了那么多测试思路,这里补一个我们自己的教训——它说明自动化检查也有盲区。
背景:做响应式时,我们给桌面布局加了一条 CSS,想把首页那个"搜索框样子"的入口链接藏起来(桌面端顶部导航栏里已经有搜索入口了):
@media (min-width: 900px) {
.mobile-head, .filter-row, .cat-strip,
.page .searchbox { display: none; }
}
问题:search.html 里真正的搜索输入框,用的也是 .searchbox 这个类名。于是桌面宽度下,整个搜索输入框被一起藏掉了——用户点进搜索页,看不到任何可以打字的框。
为什么没被发现:因为当时所有的自动检查都是"通过"的:
- 页面自检标记
data-selftest显示 PASS —— 它检查的是"渲染过程没抛异常",元素可不可见它不管; - 输入法的交互测试也是"通过"的 —— 因为测试脚本是直接按引用操作元素的:
var input = document.getElementById('searchInput');
input.value = '耳机 '; // ← 对 display:none 的元素同样有效
input.dispatchEvent(new Event('input', { bubbles: true }));
元素虽然不可见,但它在 DOM 里,赋值和派发事件照样工作。测试跑得很欢,用户却一个字也打不进去。
怎么发现的:换成测"这个元素能不能被点到"之后,一测就露馅了:
var r = el.getBoundingClientRect();
console.log(r.width, r.height); // 0 0 ← 根本没渲染
var top = document.elementFromPoint(cx, cy); // 该位置上最顶层的是谁
console.log(top === el); // false ← 被别的元素盖着
修复很简单(给首页那个入口换个类名 .searchbox-entry),但教训比修复本身重要:
- "页面没报错"和"用户能用"是两回事。 自检只能证明程序没崩,证明不了功能可用。
- 测试脚本按引用操作元素,会绕开真实用户遇到的障碍。 真实的点击要经过布局、命中测试、焦点——用
elementFromPoint和getBoundingClientRect才能模拟到这一层。 - 所以自动化检查至少要有三层:渲染有没有报错 → 关键元素是否可见可点 → 真实操作流程是否走得通。我们原来只做了第一层和第三层,中间那层恰好漏掉了这类问题。
补上可见性检查之后,两种布局、九个页面一次跑完,输出是这样:
===== 桌面 1280 =====
search.html #searchInput:OK ; #hotCloud button:OK
publish.html #fTitle:OK ; #submitBtn:OK
...
===== 手机 512 =====
search.html #searchInput:OK ; #hotCloud button:OK
...
哪一项变成"尺寸为0"或者"被 xxx 挡住",就是又出现同类问题了。
八、GitHub 代码签入记录
我们的提交粒度是按功能划分,每完成一个功能提交一次,共 25 个左右的提交,类型上分 feat / fix / test / docs 四类,作用域(store / publish / mine / utils …)直接写模块名,让人翻记录时能看出"这一批改的是哪一块"。几条值得注意的:
- 认领验证的两次改版都留下了独立提交(第一版的"预设问题 + 随机抽题 + 手打答案",和第二版的"客观题统一判定 + 删除答案归一化与错题回显")。改版过程能追溯到,比一条笼统的
refactor诚实得多; - 修复类提交写清现象和原因,例如
fix(mine): 修复标记完成时变量引用错误导致点击无反应、fix(utils): 全角转半角不应作用于存储,拆分为 clean 与 normalizeText——这两条正是第 9 节里那两个真实踩过的坑; - 测试和文档单独提交,
test: 262 个单元测试用例与端到端串测、docs: README、目录说明、PSP 表、流程图与截图。

九、遇到的困难及解决方法
这一轮我们花了最多时间的地方,是防冒领的认领验证机制——下面四个坑有三个直接出在它身上,剩下一个也是它牵出来的。每个坑都按「现象 → 我们怎么查 → 根因 → 怎么解决 → 学到什么」写。
困难一:第一版验证机制被判不准和能试探同时卡住
现象:第一版认领验证是"发布者按分类填 2–3 个只有物主知道的特征,认领人随机抽 2 题手打答案"。自测时我们拿演示数据走了一遍,结果一个真失主被拦在了门外:发布者写的标准答案是「深蓝色」,失主填的是「藏青」;另一条写「蓝色小熊贴纸」,失主填「小熊贴纸」。两次都不算通过。
我们怎么查:先把两个人的答案摆在一起逐字比对,确认不是打错字,而是"同一种颜色/同一个东西的两种说法"。接着往前推了一步——评委席上坐着的冒领者会怎么干?答案是:他会先随便答一次,系统为了告诉他错在哪,必须指出是哪一题错的;每答错一次就排除一个选项,三次机会足够把答案试出来。我们这才意识到,第一版的两个毛病是同一个根源:它把"答案"交给了自然语言。
根因:把"人不记得怎么描述"的容错问题,当成了字符串比对问题。宽容度调大——「藏青」算通过,「深蓝」也会跟着通过,等于白送;调小——真失主进不来。而"错在哪一题"这个提示看起来是好心,实际是给冒领者递了排除法的线索。
是否解决:解决了,而且是整块推翻重做:判定从"文本比对"换成"下标比对"。出题人只能出判断题和选择题,选择题由他自己写 2–4 个选项并指定正确答案;认领人一次性答完全部题目,系统只回一句「回答的细节与描述不符」,不告诉他哪一题错。第一版那套 U.normalizeAnswer()(去空格、全角转半角、忽略标点)连同它的映射规则一起删掉了——少一个能出错的规则,就少一类误判。改版过程中还踩了一个小坑:旧版的自由文本答案没法自动变成客观题,我们没有编造选项,而是把旧答案停用、在「我的发布」里提示发布者重新出题;在新题出好之前,这条信息不向认领人要求验证。
学到什么:当一个判定"怎么调都不对"时,先怀疑判定方式本身,而不是继续调参数。 换成选项之后,那两个毛病是一起消失的。
困难二:改成客观题之后,"选项"本身还是会判不准
现象:第二版上线自测,又出现了一个真失主答错的情况:发布者把选项写成「深蓝色 / 藏青」,东西其实是藏青色的同学,看见「深蓝色」觉得说的就是自己那件,随手选了——答错一次,还被系统判成"细节与描述不符"。
我们怎么查:把出题人写的选项抄下来排了一遍,发现含糊的选项和含糊的答案是同一类错误,只不过从答案层搬到了选项层。但这里有个反直觉的地方:不能一律拦。像「深蓝色 / 浅蓝色」是两种能分清的颜色,「蓝色 / 浅蓝色」也分属两个色系,如果我把这些也当成"容易选错"拦下来,发布者就会一直被误报,误报一多,这条规则就会被当成噪音忽略掉——比不拦还糟。
根因:不是"两个说法像不像"的问题,而是"这两个说法是不是同一色系里分不清的深浅"。而且"像不像"还带包含关系:「深蓝」是「深蓝色」的子串,可它们确实是同一个意思。
是否解决:解决了一半靠数据、一半靠算法。先做了一份易混词组表(LF.CONFUSABLE_GROUPS),把「深蓝 / 深蓝色 / 藏青 / 藏蓝色 / 靛蓝」归成一组、「浅蓝 / 淡蓝 / 天蓝 / 湖蓝」归成另一组,组内互斥、组间放行;再做判定:取命中的最长那个词——「浅蓝色」同时含「蓝色」和「浅蓝色」,不比长度就会归错组,把深蓝和浅蓝判成同一色。两个选项都能在词组表里找到归属时,一律以词组表为准,不再退回子串判断,避免「蓝色」和「浅蓝色」被误伤。页面上是边打字边提示,不用等到提交;发布拦截由同一份判定逻辑负责。
学到什么:防误报和防漏报同样重要。 这条规则如果误报了,发布者的第一反应不是改选项,而是关掉这条规则。
困难三:打码卡号能被"搜前 6 位"绕过——这条隐患不在出题流程里
现象:这个坑是我在翻搜索代码时撞见的。证件卡片的卡号要求"写满总位数、最多露出 6 位数字,其余用 * 顶位",比如 350504************,认领时要求搜索词和卡号位数完全一致逐位比。可我顺手试着搜「350504」——位数差得远,却命中了。
我们怎么查:顺着关键词匹配的代码找下去,发现原因很直白:卡号也是"公开特征"的一种,被一起丢进了关键词用的那个大字符串里;而关键词匹配用的是 indexOf 子串判断,「350504」当然是「350504********」的子串。位数一致这条规矩,只在逐位比较那条通道上,被我自己绕过去了。
根因:同一条数据同时走了两条规则不同的通道。逐位比较那条要求"位数一致",子串匹配那条不要求,结果弱的通道把强的通道废掉了。
是否解决:解决了,而且改的是数据层而不是界面:把"通配型特征"(带 * 的打码卡号)从普通子串匹配的语料里排除,它只走逐位比较那一关(LF.matchesWildcard),并且任意一边是 * 这一位就算过——模式里的 * 表示拾得者没露这一位,搜索词里的 * 表示失主不记得这一位。搜索页另外做了两件事:输入框右边实时把"几个字"改成"几位"并标绿标红,位数对不上时把两边的位数摆出来("你填的是 6 位,库里的证件号码是 12 位")——因为"0 条结果"和"没人捡到"在页面上长得一模一样,失主很容易就此以为东西没被捡到。测试里这条规则的边界也成对钉住了:完整号码、只记得头尾、把打码串原样粘回去都能命中;少一位、多一位、前 6 位记错,都不命中。
学到什么:排查泄漏不能只看"出题流程",要把同一条数据在所有出口上过一遍。 这个漏洞的入口不在验证题里,而在搜索里——它同样能让冒领者拿到"号码是多少位、前 6 位是什么"。
困难四:换成「其他」类不出题之后,老数据还挂着题,发布者被锁在旧流程里
现象:这一轮我们把「其他」类改成"不出验证题、描述和照片直接公开"的信任原则(它没有可锁的特征,硬出题只会让人把描述抄一遍)。改完之后,一条历史记录出了怪事:它在「我的发布」里看起来正常,可点进详情,认领人仍然被要求先答三道题;发布者想把这套题删掉,却发现发布页已经没有出题入口了——他连"删题"这个动作都做不到。
我们怎么查:先怀疑是前端没刷新,刷新之后照旧;再去查这条记录本身,发现题目还实实在在存在数据里,而且验证仍然生效。一步步往前推才明白:这类迁移只"补字段",不"清理已经不该生效的数据";而编辑保存走的是浅合并,不主动清空的话,原来那个 questions 数组会原地留下。也就是说,规则改了,历史数据却按老规则继续跑——用户被永久锁在一个连入口都没有的旧流程里。
根因:两个原因叠在一起——判定"要不要出题"的依据没跟着分类走;旧数据没有对应的迁移。
是否解决:解决了,做了三件事。一是把判断收进一个函数 LF.allowVerifyFor(),依据"这个分类有没有公开特征定义",而不是手写 category === 'other',将来真出现第二个"不出题"的分类只改这一处;它还返回三态——有特征 true、无特征 false、分类压根不在字典里 null,第三种让校验跳过,免得在"分类错误"之上再叠一条用户改不掉、也看不懂的"请至少出 3 道验证题"。二是写入路径上强制清空:不需要验证的分类,questions 一律置空,前端就算传了题目也落不了盘,needsVerify() 恒为 false,认领入口直接回"这条信息不需要验证"。三是把数据结构从第 3 版升到第 4 版,迁移时把「其他」类历史记录里残留的题目一并清掉、作答次数复位。另外,页面上不能只是把出题区一藏了事——发布者点开一个空白区块会以为功能坏了,所以选中这一类时那块地方换成一段说明,把"为什么不出题、代价是什么(没有防冒领闸门,别把唯一凭据写进公开描述)"讲清楚。测试也补了三条:带题发布会被丢弃、改成这一类后旧题清零、迁移会清掉历史题目。
学到什么:改规则的时候必须同时问一句"老数据怎么办"。 我们这次差点留下一个"用户看得见现象、却没有任何入口能修"的状态——这种状态比报错更难被发现,因为它一切"正常"。
附:一个我们没法彻底解决、只能如实写下来的问题
验证机制里还留着一个受无后端方案限制的坑:认领的作答次数记在信息上,而不是按认领人区分。也就是说,同一条信息被答满 3 次之后,后面来的人也只能走申诉通道;而申诉内容(称呼、联系方式、物品细节)在发布者那一端是可见的,本机数据又是共享的。真实环境里应该把尝试次数和申诉记录按人区分、并存到服务端,本项目作为课程作业做不到,所以我们没有把它藏在代码注释里,而是写进了 README 的「已知限制」,让每一个使用者都看得见。
十、评价你的队友
徐心铭评价林复彬:
- 值得学习的地方:坚持把业务规则和数据存储分开,一开始我觉得多此一举,后来发现正是因为这样,测试才能不用浏览器就跑起来。
- 需要改进的地方:需要更开放的思想,适当添加点新想法
林复彬评价徐心铭
- 值得学习的地方:对待细节方面比较认真,然后对于这个防冒领机制提出的很好
- 需要改进的地方:对于新的模块要有更好的规划,这次防冒领机制还不是特别完善
十一、小结
这次从原型走到代码,最大的体会是:一个"防冒领"的机制,约束的从来不只是它自己那一页。
我们最初以为认领验证就是"出几道题、答对就给联系方式",是详情页上的一个环节。做完才明白,它其实是一根线,把好几处看着不相干的地方串在了一起:题目里不能出现公开特征,所以公开特征白名单同时是"出题禁区";分类改成不出题,历史数据就得跟着迁移,否则发布者会被锁在一个没有入口的旧流程里;连"打码卡号"这条看似只在发布页生效的规则,也要在搜索里再守一遍,否则冒领者搜一下前 6 位就能把位数和前缀套出来。第九节那四个坑,本质上都是"我以为这个规则只在那一个地方生效"。
所以这次最值钱的一条经验是:把规则写进数据层,而不是写在界面上。 我们最后所有跟防冒领有关的判断——要不要验证(LF.allowVerifyFor)、题目怎么算完整、正确答案怎么剥、认领次数还剩几次、搜索时哪些字段能参与匹配——全都收在 js/store.js 一处,页面只负责显示。带来两个直接好处:一是改一处规则就全局生效,不会出现"详情页改了、搜索结果还漏着";二是规则能被单元测试一条条钉住,包括"正确答案在任何对外数据里都不存在"这种"坏了也看不出来"的地方。262 个用例里,围绕这条机制的就占了一大半。
第二个体会是:"能跑通"和"挡得住"之间,隔着一次专门的攻击自测。 第一版验证写完、自测全过,是我们自己坐到"冒领者"的位置上重新走了一遍,才发现答错时的提示等于在给排除法递线索;打码卡号那个漏洞,也不是测出来的,是拿着"这条数据还能从哪儿出去"的问题把出口一个个过了一遍才撞见的。功能测试通过,从来不等于防得住。
第三个体会:发现方案本身立不住时,越早推翻越好。 第一版已经写完、测试也全绿,我们还是把它整块换掉了——因为继续打补丁(更宽容的文本归一化、更复杂的抽题限制)只会让规则越来越难解释。换成客观题 + 统一判定之后,"判不准"和"能试探"这两个毛病是一起消失的。这也让我们后来多了一条自我要求:当一个规则怎么调都不对时,先怀疑规则本身。
附:参考资料
- 邹欣,《构建之法》(需求分析、单元测试与回归测试相关章节)
- 廖雪峰的 JavaScript 教程
- 测试框架 Mocha 实例教程 —— 阮一峰
- Commit message 和 Change log 编写指南 —— 阮一峰
- Mocha 官方文档
浙公网安备 33010602011771号