软工第二次结对作业:失物招领程序

这个作业属于哪个课程 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 代码实现思路

一句话概括:把「业务规则」和「界面」彻底分开,让规则能被测试,让界面只管显示。

原型阶段我们已经把功能列清楚了,但真写代码时遇到的第一件事是:哪些东西必须做对,做错了后果严重?

想了一圈,有四个:

  1. 验证题的正确答案不能泄露——这是防冒领机制的根基,一旦答案能从列表页、详情页或答题页的数据里扒出来,整个设计就废了;
  2. 状态流转不能乱——东西已经还回去了,信息还挂着「待认领」,别人就会白跑一趟,这正是需求里要解决的痛点;
  3. 只有发布者能改自己的信息——没有账号体系的前提下,这条得靠本机身份标识来兜;
  4. 搜索得搜得到——搜「校园卡」找不到「校园卡一张(学号 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,首页选中分类时显示在筛选区旁(手机端在筛选条下面):

分类 页面上的引导语
证件卡片 🔍 本类只公开卡号:请按证件号搜索(记不全的位用 * 顶位,位数要和卡号一样长),姓名、院系、卡号之外的细节都不公开。(卡号(打码))(就是全部可搜的公开特征)
电子产品 🔍 本类只公开品牌和型号:请按品牌或型号搜索,颜色、外观、保护壳都不在公开信息里。(品牌、型号)(就是全部可搜的公开特征)
耳机 🔍 本类只公开品牌和颜色:请按品牌或颜色搜索,充电盒、外观磨损这类细节不公开。
雨伞 🔍 本类只公开伞面颜色和柄型:请按颜色或柄型搜索,图案、划痕这类细节不公开。
…… 有特征的分类各一条,写法同上
其他 🔍 这一类不好指定公开特征,也不出认领验证题:描述和照片直接公开,请翻列表(或用描述里的词、按地点)找,翻到了直接联系发布者核对。

三个决定值得记一笔:

  1. 不是"请搜 X",而是"请搜 X,Y 搜不到"。 只告诉失主搜得动的字段,他不知道"颜色"根本不在公开面里,照样会去搜。所以每条引导语都带一句"哪些细节不公开"。
  2. 显不显示由字典决定,不维护第二份名单。 LF.searchHintFor() 只读 LF.CATEGORIES[].searchHint 这一份数据:写了就显示、没写就返回空串由页面隐藏。所以脏数据里的未知分类天然没有引导,页面不用维护分类名单。
  3. 引导语自己会"认错"。 特征名和引导语存在两个地方,最容易出的岔子是改了字段名忘了改文案。所以 LF.searchHintFor() 会检查 searchHint 里有没有提到字典里的字段名,没提到就把它附在后面自曝其短;validate.spec.js 里还有一组成用例直接断言"每个特征名都必须出现在引导语里"("搜索引导:每个分类都要告诉失主该搜什么"一节)。

原有的特征筛选按钮一个没删。 引导语讲的是"关键词该怎么搜",而侧栏那组按品牌/颜色的筛选按钮本身就能用(点「华为」直接筛出华为耳机),两者互补:会搜的用搜索框,不会搜的点按钮。这一条是刻意保留的取舍。

图 2:首页选中「证件卡片」时,侧栏出现这条引导。引导语的位置就在特征筛选下面,失主不会错过。

02-首页搜索引导

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

03-其他类引导

4.3.1 补上对照组:「其他」类不出验证题

把上面那套"锁定特征 + 出题验证"推到底,就会撞到一个绕不开的分类:「其他」。它之所以是「其他」,正是因为这一类物品没有客观、能锁死的细节——一本书、一串钥匙、一个说不清型号的充电器。我们对它做过一个自问:这一类该出什么题? 答案是:出题人只能把描述里的细节再抄一遍("扉页上写着谁的名字"配上描述里的"扉页有名字"),同一份信息写两次,而且冒领者照着公开描述就能选对——在一类根本没有可锁特征的信息上强制出题,只会逼人编题。

所以这一类改成对照组,走信任原则:不设认领验证题,描述和照片直接公开,联系方式直接可见,见到的人直接联系发布者核对。

分类 LF.allowVerifyFor() 出题 谁看得到联系方式
有公开特征的八类 true 必须出 3–5 题 答对全部题目的人
其他 false 不出题 所有人(公开)
字典里没有的分类(脏数据) null 不校验 分类字段自己已经报错,不再叠加

四个实现上的决定,都是被具体问题逼出来的:

  1. 判断收在一个函数里,页面不写分类名。 依据是"这个分类有没有公开特征定义"(LF.featuresFor),不是手写 category === 'other':将来真出现第二个"不出题"的分类,只改这一处。返回三态而不是布尔是为了第三行的脏数据:分类压根不在字典里时,"该不该出题"无从判断,返回 null 让校验跳过,免得在"分类错误"之上再叠一条用户改不掉、也无从理解的"请至少出 3 道验证题"。
  2. 数据层说了才算,前端藏了不算数。 store.create / store.update 对「其他」强制把 questions 置空(和"描述按分类置空"是同一个套路),前端就算传了题目也落不了盘;needsVerify() 对「其他」恒为 false,认领入口 startClaim() 直接回"这条信息不需要验证"。
  3. 旧数据必须就地清掉(DATA_VERSION 3 → 4)。 演示数据 seed_6 原本给这一类挂着 4 道题,v3 时代的真实数据也可能有。如果只改新流程,这些老记录会继续按老规矩要求认领者答题,而发布页已经不再提供题目编辑入口——发布者连"把这套题删掉"都做不到,等于被永久锁在旧流程里。所以迁移把它们清空、作答次数一并复位。
  4. 页面要给"去处",不能只是把出题区一藏了事。 发布者点开一个空白的出题区会以为功能坏了。所以选中「其他」时,出题区换成一整块说明:为什么不出题、描述和照片会直接公开、代价是没有防冒领闸门(所以别把唯一凭据——"书里夹着一张写名字的借书凭条"——写进公开描述)。详情页、我的发布、发布成功页也各说一句,免得认领人以为页面坏了、发布者以为自己漏填了。

这条改动是有代价的,我们把它写进了「已知限制」:这一类没有防冒领闸门,理论上谁先看到都能联系发布者。换来的是这个分类终于有一个说得通的流程,而不是一个逼着人编题的流程。

图 4:发布页选中「其他」——出题区整块消失,换成信任模式说明(含代价提醒);图 5 是切回「电子产品」的样子,出题区立刻回来。

04-发布有特征分类要出题

05-发布其他类不出题

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

06-详情其他类信任原则

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

07-我的发布

4.4 关键实现的流程图:认领验证(含一次大改版)

这是本项目最核心的一段逻辑,也是第一次作业原型里最有价值的原创点。

要解决的问题很具体:招领信息如果把联系方式直接公开,任何人都能看到,真正的主人反而可能被抢先联系。 但完全不给联系方式,又没法线下交接。

第一版(原型方案):发布招领信息时按物品分类设置 2–3 项只有物主才知道的特征(证件卡片问卡面姓名 / 卡号后四位 / 卡面标记,雨伞问伞面颜色 / 伞柄特征……),认领时随机抽 2 项,让认领人手打答案,全部答对才解锁联系方式。

做完自测之后,我们对着评审意见把这条路重走了一遍,发现两个绕不开的毛病:

毛病 具体表现 后果
判不准 发布者写「深蓝色」,失主记得是「藏青」;发布者写「蓝色小熊贴纸」,失主写「小熊贴纸」 真失主被拦在外面。为了宽容就得做文本归一化,而"宽容到什么程度"本身没有正确答案
能试探 为了让失主知道错在哪,页面必须指出答错的题(否则他无从下手) 每答错一次就排除一个选项,3 次机会足够把答案试出来,限制次数形同虚设

第二版(本仓库采用的方案):纯客观题 + 限制次数。 把判定从"文本比对"换成"选项比对",从根上消掉这两个问题:

  • 拾得者出题,只允许判断题和选择题,题量 3–5 题(建议 4 题),选择题 2–4 个选项并指定正确答案;系统再按物品分类给出题模板,降低出题门槛;
  • 认领者一次性答完全部题目再提交,每题只能点选项,没有自由文本;
  • 系统统一判定,只回"通过 / 不通过":未通过时提示语统一为「回答的细节与描述不符」,不告诉他是哪一题错——没有排除法的线索,3 次机会就真的只剩 3 次;
  • 最多答 3 次,答对不消耗次数;发布者换了题目则重新给满 3 次;
  • 3 次全败后解锁【申诉】,认领者写下只有物主才知道的细节和联系方式,进入人工审核通道,由拾得者判断是否交还。

认领验证流程图

图里有四个地方是特意设计的:

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

图 13:这一版答题页长这样——一次性看到全部题目、一次答完,没有自由文本,也没有"上一题/下一题"的分页(分页反而会让人以为可以回头改)。

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: '回答的细节与描述不符'
  };
}

两个刻意的取舍:

  1. 判定是"全部答对",而且不告诉认领者错在哪一题。 第一版为了让失主知道错在哪,必须回传 failed: [{ q, input }];这一版把它彻底删掉了——一旦告诉了他,3 次机会就变成"每次排除一题"的试探过程。代价是失主可能觉得"为什么不说清楚",所以结果页把理由写明了("否则反复试几次就能把答案试出来"),并给出申诉出口。记录里的 correct 字段只在 listClaims() 里返回,而那个接口有发布者校验。

  2. 比对不再需要文本归一化。 第一版有一套 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 创意独到之处及意义

核心特点:客观题认领验证 + 限制次数 + 人工审核兜底,把"谁有资格看到联系方式"从口头约定变成程序判断。

意义在哪里?我们回看第一次作业梳理的痛点:信息分散、易被淹没、查不到进度。但真正下手做的时候发现还有一个更尖锐的问题:

招领信息挂出去,联系方式给谁?

  • 全公开 → 任何人都能看到,冒领几乎没有成本。真失主还没看到消息,东西可能就被别人领走了;
  • 完全不公开 → 失主联系不上拾得者,信息等于白挂。

这是个两难。我们的解法是引入一个只有物主能通过的门槛:拾到东西的人出几道只有物主答得对的客观题,认领人全部答对才解锁联系方式。

这个设计的价值有三层:

  1. 对拾得者:敢把信息挂出来了,因为不怕被冒领;
  2. 对失主:能证明"这东西确实是我的",反而更容易拿回来;
  3. 对平台:信息可信度提高,别人也愿意用。

而且它顺带解决了一个隐私问题:发布者的联系方式默认不公开,只有通过验证的人能看到,减少了联系方式被爬走的风险。

另外三个附加特点(任务书里点名提到的那两类我们都做了):

特点 说明
一键复制联系方式 详情页和验证通过页都有,点一下进剪贴板。复制失败时不是只弹句错误就完了,会自动弹出可手动复制的输入框,事情还能办成
按物品分类 / 地点筛选 首页支持分类、地点、状态三个维度的筛选,选项右侧实时显示条数;桌面用左侧栏,手机用图标条 + 下拉
搜索历史 + 热门搜索 最近 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
}

四个关键设计点:

  1. 题目和答案存在一起,但下发的对象里没有答案。 toPublic() 用 publicQuestion() 重新造一份只含 { id, type, typeName, stem, options } 的题目数组;answer 只活在两个地方:数据层内部判定时,以及发布者编辑时的 getEditable()。

  2. 题型只有判断题和选择题,且结构由数据层兜底。 判断题的选项固定为「正确 / 错误」(发布者传什么都不算数);选择题里的空选项行会被丢掉、正确答案下标跟着重排,所以前端多传一行空选项也不会把答案对错位。

  3. 申诉是一次落库的表单,不是一句弹窗文案。 只做一个"请线下联系发布者"的弹窗最省事,但发布者就没有可判断的凭据;所以申诉内容(称呼、联系方式、物品细节)存在信息上,发布者在「我的发布 → 人工审核」里逐条处理:同意交还即解锁联系方式并生成线下交接编号,驳回则附一句理由回给申诉人。

  4. 申诉的解锁条件是"剩余次数为 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:验证未通过的样子——只给一句统一提示与剩余次数,不显示哪题错。

09-验证未通过

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

10-验证通过凭证码

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

11-三次用完解锁申诉

时间的可注入性:把不确定性关进盒子里

createStore(storage, options) 允许传入固定的 now(测试夹具里固定成 2026-10-02T10:00:00)。时间相关的逻辑必须能在测试里固定,否则测试会间歇性地红。第一版还额外注入了随机源(因为要随机抽题),改成"一次答完全部题目"之后,随机性从这条链路里彻底消失,注入的参数也就只剩 now——能删掉的不确定性就别留着。涉及"3 次失败后锁定"的用例,就老老实实失败三次再断言,而不是想办法"快进"。

状态流转:把"重复点也没事"做进数据层

markDone() 是幂等的:第二次调用返回 unchanged: true,并且不改动原有的完成时间——否则"已归还于今天"会变成"已归还于刚才",记录就不准了。归属校验(post.ownerId !== actor 直接拒绝)也在这一个函数里,不靠按钮是否显示。

5.4 实现成果展示

以下截图都是当前仓库的真实运行画面,编号与正文各处的引用一致。

桌面端首页:左侧筛选栏(分类 / 地点 / 状态,各选项带实时条数),右侧卡片网格,卡片直接标出寻物/招领与当前状态;选中分类时出现搜索引导。

01-首页桌面

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

08-搜索结果

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

12-详情联系方式锁定

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

06-详情其他类信任原则

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

04-发布有特征分类要出题

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

05-发布其他类不出题

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

07-我的发布

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

13-认领验证答题页

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

14-申诉表单

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

15-手机端三屏

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

16-单元测试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 测试人员如何运行

  1. 把 校园失物招领Web 文件夹整个下载到本地;
  2. 用谷歌浏览器打开根目录下的 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 的测试报告。

16-单元测试262全绿

7.2 一份简易教程:从零开始给这个项目加一条测试

  1. 第一步:想清楚要测什么。 不是"测这个函数",而是"什么情况会出错"。比如发布表单,要测的不是 validatePost 能返回结果,而是"漏填物品名称时会不会拦住"。
  2. 第二步:造一份必然合法的输入作为基准。 test/specs/helpers.js 里放了 validFound() / validLost() 两份基准数据(含一套标准验证题和一份标准作答),每个用例只在这份基准上改一个字段。
  3. 第三步:每次只改一个字段,测它的边界。 例如把 title 改成 '卡'(1 个字)断言报 title 错,改成 '校园'(2 个字)断言不报——下边界和边界内侧成对写。
  4. 第四步:把当前时间固定下来。 不固定 now,涉及"相对时间显示""挂了多久该提醒"的断言会随运行时刻改变,测试就会间歇性地红。(第一版还要固定随机源,因为那时是随机抽题;改成"一次答完全部题目"之后,随机性从这条链路里消失,注入的参数只剩 now。)
  5. 第五步:用内存存储隔离每个用例。 每个 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),但教训比修复本身重要:

  1. "页面没报错"和"用户能用"是两回事。 自检只能证明程序没崩,证明不了功能可用。
  2. 测试脚本按引用操作元素,会绕开真实用户遇到的障碍。 真实的点击要经过布局、命中测试、焦点——用 elementFromPoint 和 getBoundingClientRect 才能模拟到这一层。
  3. 所以自动化检查至少要有三层:渲染有没有报错 → 关键元素是否可见可点 → 真实操作流程是否走得通。我们原来只做了第一层和第三层,中间那层恰好漏掉了这类问题。

补上可见性检查之后,两种布局、九个页面一次跑完,输出是这样:

===== 桌面 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 表、流程图与截图。

image


九、遇到的困难及解决方法

这一轮我们花了最多时间的地方,是防冒领的认领验证机制——下面四个坑有三个直接出在它身上,剩下一个也是它牵出来的。每个坑都按「现象 → 我们怎么查 → 根因 → 怎么解决 → 学到什么」写。

困难一:第一版验证机制被判不准和能试探同时卡住

现象:第一版认领验证是"发布者按分类填 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 个用例里,围绕这条机制的就占了一大半。

第二个体会是:"能跑通"和"挡得住"之间,隔着一次专门的攻击自测。 第一版验证写完、自测全过,是我们自己坐到"冒领者"的位置上重新走了一遍,才发现答错时的提示等于在给排除法递线索;打码卡号那个漏洞,也不是测出来的,是拿着"这条数据还能从哪儿出去"的问题把出口一个个过了一遍才撞见的。功能测试通过,从来不等于防得住。

第三个体会:发现方案本身立不住时,越早推翻越好。 第一版已经写完、测试也全绿,我们还是把它整块换掉了——因为继续打补丁(更宽容的文本归一化、更复杂的抽题限制)只会让规则越来越难解释。换成客观题 + 统一判定之后,"判不准"和"能试探"这两个毛病是一起消失的。这也让我们后来多了一条自我要求:当一个规则怎么调都不对时,先怀疑规则本身。


附:参考资料


posted @ 2026-10-09 00:46  matougui-x  阅读(6)  评论(0)    收藏  举报