软件工程第二次结对作业

2026秋软件工程结对编程作业(第二次)——校园失物招领

项目 内容
这个作业属于哪个课程 202601软件工程
这个作业要求在哪里 2026秋软件工程第二次结对作业之程序实现
这个作业的目标 基于第一次结对作业的原型设计,完成核心功能的代码实现,使「发布信息—浏览或搜索—查看详情—联系发布者—更新状态」的基本流程清晰、可用
我的学号 022403140
结对同学 张锦昊 042402115(博客:zhuangzhu,GitHub:zhuangzhu600)
GitHub 项目地址 042402115-022403140

第一次结对作业里,我们围绕「校园里丢了东西不知道去哪找、捡到东西不知道交给谁」这个痛点,完成了一套移动端原型的设计,走通了首页浏览、发布、搜索、详情、我的发布这五个页面的交互。但原型里的列表是写死的样例,表单提交了也不会真的保存。这一次的任务,就是把它变成一个数据真实流动的程序:发布的瞬间信息入库,搜索能真正命中,联系方式能一键复制,东西找到之后发布者能把这条信息标记为「已找到」,后来的同学不再白跑一趟。

呈现形式我们选了 PC 端 Web 网页,用谷歌浏览器直接打开 index.html 就能运行,不需要安装任何环境。


一、具体分工

成员 负责内容 在提交记录中的位置
张锦昊 042402115(zhuangzhu600) 需求梳理与原型对齐、页面骨架与全部样式、数据规则模块 core.js、持久层 storage.js、底部导航与首页、发布页与成功页、单元测试、README 与文档 前 10 个 commit + 收尾的 README 提交
周恪玄 022403140(34zkx) 搜索页(关键词 + 分类筛选 + 只看进行中)、详情页、一键复制联系方式、我的发布与快捷状态更新、无效 ID 兜底处理 中间 6 个 commit,fork 后经 PR #1 合并

协作方式上,张锦昊建仓库并按功能块逐个提交第一阶段;我 fork 之后在 feature/stage2 分支上开发第二阶段,完成后发 Pull Request,由他 review 合并。动手之前,我们先把数据模型和 Core 函数的签名写进了 docs/接口约定.md,两个人对着同一份约定开发,我第二阶段接入第一阶段的存储层时,没有因为接口理解不一致返工。


二、PSP 表格

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 30 35
Estimate 估计这个任务需要多少时间 30 35
Development 开发 565 715
Analysis 需求分析(包括学习新技术) 60 80
Design Spec 生成设计文档 30 35
Design Review 设计复审 20 15
Coding Standard 代码规范(为目前的开发制定合适的规范) 15 15
Design 具体设计 50 70
Coding 具体编码 300 380
Code Review 代码复审 30 40
Test 测试(自我测试,修改代码,提交修改) 60 80
Reporting 报告 85 105
Test Report 测试报告 30 35
Size Measurement 计算工作量 15 15
Postmortem & Process Improvement Plan 事后总结,并提出过程改进计划 40 55
合计 680 855

实际总耗时约为预估的 1.26 倍,超支集中在三处,对应的教训也明确:

阶段 偏差 原因与改进
需求分析 +20 分钟 原型到程序的差距比预想大:原型里「点提交跳成功页」只是一个跳转,程序里要先校验、再写入、写入成功才允许跳转。下次做原型时应顺手标注每个交互背后的数据动作
具体设计 +20 分钟 初版方案被推翻:第一版把校验、筛选逻辑直接写在页面脚本里,写到搜索页时发现和首页的筛选重复了,才下决心抽出独立的规则模块。如果设计阶段先想清楚「哪些逻辑会被多个页面共用」,这次返工可以避免
编码 / 测试 +80 / +20 分钟 纯空格标题、损坏的本地数据、写入失败回滚这些问题都是测试用例逼出来再回头改的。教训是测试设计应该和编码同步开始,而不是编码完成之后

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

3.1 代码实现思路

设计可以从「一条信息的旅程」说起。一条失物信息在这个系统里会经历:从表单诞生 → 通过校验 → 写入本地存储 → 被浏览和搜索命中 → 在详情页被查看 → 联系人出现 → 被发布者标记为结束。我们要做的,是让这条旅程上的每一步都有明确的代码负责,且每一步都能单独验证。

由此得到三个技术决策:

第一,不用框架,也不设构建步骤。 作业要求测试人员下载后用谷歌浏览器打开 HTML 就能看到预期效果,任何打包工具都会成为复现的障碍。全部代码就是 HTML + CSS + 原生 JavaScript,普通 <script> 标签按依赖顺序加载。

第二,业务规则集中在一个不碰界面的模块。 校验、检索、排序、状态流转这些规则被多个页面共用——首页要筛选,搜索页也要筛选,详情页和我的发布都能改状态。如果每个页面各写一份,迟早出现两边行为不一致。所以它们全部收进 js/core.js,而且这个文件里只有纯函数:不读 DOM、不写 localStorage,同样的输入必然得到同样的输出。这个决定同时解决了可测试性问题——纯函数可以直接被 Node.js 加载做单元测试,不需要浏览器。

第三,存储读写只有一个出入口。 页面不直接碰 localStorage,统一经过 js/storage.js。这样做的好处是:首次启动写入演示数据、识别「哪些是我发布的」、读出损坏数据时自动回退、写入失败时回滚内存状态,这些横切逻辑都有唯一的位置可放,不会散落在各个页面里。

页面层因此变得很薄:js/app.js 管导航、首页、发布页和共用卡片渲染,js/pages/ 下三个文件各自对应搜索、详情、我的发布,每个页面的职责只有「取数据 → 调 Core → 渲染」。

一条信息记录的字段设计如下:

Item {
  id,                          // 唯一标识(时间戳 + 计数 + 随机串)
  type,                        // 'lost' 寻物启事 | 'found' 失物招领
  category,                    // 物品分类:card / keys / bottle / umbrella / headphones / book / bag / other
  title, description,          // 标题(≤30 字)/ 详细描述(≤200 字)
  location,                    // 地点(预设 11 个校园地点,避免自由输入造成的检索困难)
  date,                        // 发布日期 YYYY-MM-DD(本地时区)
  contact,                     // 联系方式(≤50 字)
  poster,                      // 发布者显示名
  status,                      // 'active' 进行中 | 'resolved' 已解决
  ownerId                      // 发布者本机标识,状态修改的权限依据
}

3.2 关键实现的流程图与数据流图

核心业务流程(用户视角):


图 1 核心业务流程:使用主线 / 发布流程 / 状态维护三段闭环

整个系统对用户呈现为一个五步闭环。值得强调的是图里的两条「岔路」:发布时校验不通过,会按字段标红、返回修改,而不是悄悄失败;修改状态时如果操作者不是发布者本人,会被规则层拒绝。这两条岔路正是对「不能只按顺利成功的路线设计产品」的回应。

关键数据流(代码视角):


图 2 关键数据流:页面层 ⇄ 持久层 ⇄ 规则层,所有页面读写同一份数据

写入链路:表单数据 → Core.validatePost 校验 → Core.createItem 构造记录 → store.add 落库 → localStorage,任何一环失败都中止并反馈。读取链路:localStorage → store.load(坏数据护栏)→ Core.searchItems 组合过滤 → Core.sortItems(进行中优先,同状态按日期新到旧)→ 页面渲染。所有页面读写同一份 store,状态修改后立即在所有入口可见,不存在「详情页改了、首页没变」的窗口期。

3.3 重要代码片段及解释

① validatePost:校验结果按字段返回,而不是一个布尔值

function validatePost(form) {
  var errors = {};
  form = form || {};
  var title = String(form.title || '').trim();
  var contact = String(form.contact || '').trim();

  if (!title) errors.title = '请填写标题';
  else if (title.length > TITLE_MAX) errors.title = '标题不能超过 ' + TITLE_MAX + ' 字';

  if (!form.location) errors.location = '请选择地点';
  else if (LOCATIONS.indexOf(form.location) === -1) errors.location = '地点不在可选范围内';

  if (!contact) errors.contact = '请填写联系方式';
  else if (contact.length > CONTACT_MAX) errors.contact = '联系方式不能超过 ' + CONTACT_MAX + ' 字';

  if (String(form.description || '').length > DESC_MAX) {
    errors.description = '描述不能超过 ' + DESC_MAX + ' 字';
  }
  if (!form.category || !CATS[form.category]) errors.category = '请选择物品分类';

  return { ok: Object.keys(errors).length === 0, errors: errors };
}

如果校验只返回 true/false,页面只能弹一句「填写有误」,用户不知道错在哪。返回 {字段名: 提示语} 的映射之后,发布页可以把每个错误精确地标红在对应输入框下面。另外注意所有输入先 trim 再判断——「只敲了几个空格」必须视为没填,这是测试用例专门覆盖的边界。

② searchItems:四种条件在同一条流水线里叠加

function searchItems(items, opts) {
  opts = opts || {};
  var q = String(opts.query || '').trim();
  var cat = opts.category || 'all';
  var type = opts.type || 'all';
  return (items || []).filter(function (it) {
    if (opts.onlyActive && it.status !== 'active') return false;
    if (type !== 'all' && it.type !== type) return false;
    if (cat !== 'all' && it.category !== cat) return false;
    return matchesQuery(it, q);
  });
}

关键词、物品分类、信息类型、「只看进行中」四个条件在这里短路叠加,任一不满足即排除。首页的类型筛选、搜索页的全部过滤都调用这同一个函数——这是「行为一致」的制度保障,而不是靠两个页面的作者互相记得对齐。关键词命中标题、描述、地点任一字段,大小写不敏感。

③ toggleStatus:权限防线放在规则层,且不动原数据

function toggleStatus(item, requesterId) {
  if (!item) return { ok: false, error: '没有找到这条信息' };
  if (item.ownerId !== requesterId) {
    return { ok: false, error: '只能修改自己发布的信息' };
  }
  var next = item.status === 'active' ? 'resolved' : 'active';
  var copy = {};
  for (var k in item) copy[k] = item[k];
  copy.status = next;
  return { ok: true, item: copy };
}

「只有发布者能改状态」这条规则,界面上只是不给别人显示按钮,但真正的防线在这个函数里——即使绕过界面直接调用,没有匹配的 ownerId 也改不动。其次它不修改传入的对象,而是复制一份再改:调用方的数据不会被悄悄变化,单元测试可以断言「原对象保持不变」。已解决状态对外显示什么文案由 resolvedLabel 按类型区分:寻物是「已找到」,招领是「已归还」。


四、附加特点设计与展示

作业要求的核心流程之外,我们做了三个自认为有实际价值的设计。

特点一:一键复制联系方式。 失物招领的最终目的是让双方建立联系,而「长按选中 QQ 号再复制」在手机上很容易选错字符。详情页的「📋 复制」按钮把这一步压缩成一次点击。实现上有个必须处理的坑:navigator.clipboard 只在 HTTPS、localhost 等安全上下文可用,而本作业的交付形式是双击打开 HTML 文件(file:// 协议),现代 API 很可能直接不存在。所以我们准备了降级路径,并保证两条路径都有明确反馈:

function copyContact(text) {
  function fallback() {
    var ta = document.createElement('textarea');
    ta.value = text;
    ta.style.position = 'fixed';
    ta.style.opacity = '0';           // 不可见但可以被选中
    document.body.appendChild(ta);
    ta.select();
    var ok = false;
    try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
    document.body.removeChild(ta);    // 用完立刻移除,不污染 DOM
    if (ok) toast('联系方式已复制,快去联系对方吧');
    else toast('复制失败,请长按或 Ctrl+C 手动复制');
  }
  if (navigator.clipboard && navigator.clipboard.writeText) {
    navigator.clipboard.writeText(text).then(function () {
      toast('联系方式已复制,快去联系对方吧');
    }, fallback);                     // 现代 API 失败时降级
  } else {
    fallback();                       // file:// 等环境直接走降级
  }
}

特别地,降级也失败时会明说「请手动复制」,而不是假装成功——虚假的「已复制」会让用户带着错误信息去粘贴,比不复制更糟。

特点二:「只看进行中」开关。 随着信息累积,已解决的条目会淹没仍在寻找的条目。打开开关后列表只保留进行中的信息。它没有单独写过滤逻辑,而是作为 searchItems 的 onlyActive 选项与关键词、分类自然叠加——这也顺带验证了 3.3 中组合检索设计的扩展性。它和「已找到/已归还」状态流转配合,形成完整的信息生命周期管理。

特点三:用户输入全量转义。 标题和描述由用户自由输入,如果不经处理拼进 HTML,输入 <img src=x onerror=alert(1)> 之类的内容就会变成真实执行的脚本(XSS 注入)。所有用户输入写入页面前统一经过 core.js 里的 escapeHtml 转义,并用恶意输入写了专门的测试用例。这是用户看不见、但缺席时会出大事的设计。

成果展示:


图 3 详情页点击「复制联系方式」,底部弹出成功提示

图 4a 开关关闭:已找到的「双肩包」正常显示

图 4b 开关打开:已解决信息被正确过滤,列表为空

图 5a 关键词「钥匙」+ 分类「校园卡」:无匹配,给出空态提示

图 5b 关键词「钥匙」+ 分类「钥匙」:命中 2 条,条件为「与」关系

五、目录说明与使用说明

5.1 目录组织

042402115-022403140/
├── index.html            入口页面,谷歌浏览器直接打开即可运行
├── css/
│   └── style.css         全部样式(还原第一次作业原型的移动端界面)
├── js/
│   ├── core.js           业务规则:校验、检索、排序、状态流转、文案(纯函数,可在 Node 中测试)
│   ├── storage.js        localStorage 读写唯一出入口:演示数据、本机身份、坏数据护栏、写入回滚
│   ├── app.js            导航与页面注册、首页、发布页、成功页、共用卡片、一键复制
│   └── pages/
│       ├── search.js     搜索页(关键词 + 物品分类 + 只看进行中)
│       ├── detail.js     详情页(查看详情、复制联系方式、更新状态)
│       └── my-posts.js   我的发布(本人信息管理与快捷状态更新)
├── tests/
│   ├── core.test.js      规则模块的单元测试
│   └── storage.test.js   持久层的单元测试(含异常与回滚)
├── docs/
│   ├── 接口约定.md        开发前双方对齐的数据模型与函数签名
│   └── img/              本文使用的流程图源文件
└── README.md             目录、运行、测试说明

组织原则就是 3.1 说的三层:core.js 是规则,storage.js 是存储,app.js 与 pages/ 是界面。依赖方向只有一个——界面依赖存储和规则,规则不依赖任何人,所以测试规则层时不需要浏览器。

5.2 测试人员如何运行

看网页:下载仓库全部文件(Code → Download ZIP,解压),用谷歌浏览器打开根目录的 index.html 即可,无需安装任何东西。首次打开自动写入 10 条演示数据;之后你发布的信息、改过的状态都存在浏览器本地,刷新不丢。想恢复初始状态,清除该站点的浏览器数据即可。

建议的走查路径:首页切换「全部 / 失物 / 招领」→ 发布一条寻物(试试漏填必填项看标红)→ 搜索关键词并叠加分类、「只看进行中」→ 点卡片进详情复制联系方式 → 到「我的」把刚发布的信息标记为「已找到」→ 回首页确认状态已变化。

跑单元测试(可选):安装 Node.js(≥18)后在项目根目录执行 node --test,31 个用例应全部通过。


六、单元测试

6.1 工具选择与学习过程(附简易教程)

我们用的是 Node.js 内置的 node:test,没有引入 Mocha、Jest 等第三方框架。决定性理由是复现成本:第三方框架需要 npm install 拉一堆依赖,而 node:test 只要机器上有 Node.js(≥18)就能跑,测试人员一条命令就能验证。学习路径是 Node.js 官方文档的 Test runner 章节,再对照作业推荐的 Mocha 教程迁移写法——两者都是「定义用例 + 断言」的风格,差别只在要不要安装。

简易教程(跟着做只要五分钟):

① 让被测代码能被 Node 加载。我们的做法是文件头加一段环境判断,浏览器里挂到全局 Core,Node 里走 module.exports:

(function (root, factory) {
  var api = factory();
  if (typeof module !== 'undefined' && module.exports) {
    module.exports = api;               // Node.js(单元测试)
  } else {
    root.Core = api;                    // 浏览器全局
  }
})(typeof self !== 'undefined' ? self : globalThis, function () { /* ... */ });

② 写测试文件。一个用例就是「准备输入 → 调用 → 断言」三步:

const test = require('node:test');
const assert = require('node:assert/strict');
const Core = require('../js/core.js');

test('纯空格标题视为未填写', () => {
  const r = Core.validatePost({ ...GOOD_FORM, title: '   ' });
  assert.equal(r.ok, false);
  assert.ok(r.errors.title);
});

③ 在项目根目录运行 node --test,它会自动找到 tests/ 下所有 .test.js 执行并汇总结果。改完代码随手跑一遍,回归就有了保障。

6.2 部分测试代码与所测函数

两个测试文件共 31 个用例,覆盖 core.js 全部导出函数和 storage.js 的持久化逻辑。节选三组:

validatePost 的边界(长度上限、白名单):

test('标题超长被拒绝', () => {
  const r = Core.validatePost({ ...GOOD_FORM, title: 'a'.repeat(Core.TITLE_MAX + 1) });
  assert.equal(r.ok, false);
  assert.ok(r.errors.title);
});

test('地点必须在预设范围内', () => {
  const r = Core.validatePost({ ...GOOD_FORM, location: '火星基地' });
  assert.equal(r.ok, false);
  assert.ok(r.errors.location);
});

toggleStatus 的权限与不可变性:非发布者切换状态返回错误;发布者本人切换成功,且断言传入的原对象 status 未被修改(函数返回的是副本)。

storage.js 的异常路径:用内存后端模拟 localStorage——存入损坏的 JSON 后读取,应自动回退到演示数据而不是白屏;让 setItem 抛异常模拟写入失败,内存中的数据应回滚到写入前,不能出现「页面上有、刷新后没了」的不一致。

6.3 构造测试数据的思路

以白盒方法为主,对着每个函数的分支和边界构造输入,站在「测试人员会怎么刁难」的角度穷举:

  1. 正常路径:合法表单、本人操作、存在的 ID——先保证基本盘正确。
  2. 边界值:空串、纯空格、正好 30 字、31 字的标题——专抓差一错误和 trim 遗漏。
  3. 非法取值:spaceship 分类、「火星基地」地点、stolen 类型——验证白名单不是摆设。
  4. 越权:拿别人的 ownerId 去改状态——权限必须在规则层拦住,而不是只在界面上藏按钮。
  5. 存储异常:损坏 JSON、写入抛异常——这是用户真实会遇到的(手动改坏浏览器数据、隐私模式写入受限),也是最容易漏测的。
  6. 副作用检查:状态更新后原数组、原对象必须原封不动——保证纯函数性质,防止隐性耦合。

自我评价:31 个用例覆盖了规则层的全部分支和持久层的主要异常路径,且任何人 clone 后一条命令即可复现,满足本程序的测试要求。短板是 DOM 渲染层没有自动化覆盖(卡片渲染、toast 提示靠人工走查验证),受工具链限制未引入浏览器自动化测试,这一点如实说明。


图 6 项目根目录执行 node --test:31 个用例全部通过

七、GitHub 代码签入记录

仓库按功能块提交,每块完成、验证页面可运行后 commit 一次;第二阶段在我 fork 的 feature/stage2 分支上开发,经 Pull Request #1 由张锦昊 review 后合并回 main。


图 7 提交记录:第一阶段 10 个 commit → PR #1 合入第二阶段 6 个 commit → README 收尾

图 8 Pull Request #1:fork + PR 协作过程,6 个 commit 经 review 后合并

八、开发中遇到的问题与解决

问题一:复制按钮在 file:// 协议下失灵

  • 问题描述:一键复制在开发环境(localhost)下一切正常,但双击 HTML 文件直接打开时,点了毫无反应——而这恰恰是作业要求的交付和评分方式。
  • 做过哪些尝试:先是怀疑事件绑定出了问题,在控制台逐行排查后才发现根源:此时 navigator.clipboard 是 undefined,Clipboard API 只在安全上下文(HTTPS/localhost)中可用。期间也想过直接砍掉这个功能,但它解决的是「联系对方」这个最关键的动作,舍不得丢。
  • 是否解决:解决。增加了 document.execCommand('copy') 的降级路径(见第四节的代码),两条路径都配上 toast 反馈;连降级也失败的极端情况,也明确提示用户手动复制,而不是假装成功。
  • 有何收获:「在我机器上能跑」和「在评分者的环境下能跑」之间隔着真实的环境差异,功能必须在目标交付环境里验证。此后我把「Chrome 直接打开 file://」列为每次提交前的固定走查项。

问题二:点开不存在的信息,详情页一片空白

  • 问题描述:联调走查时发现一个漏洞:如果链接到的物品 ID 在数据里不存在(比如信息刚被发布者处理掉,或者本地数据被清过),详情页会渲染出一个空壳,没有任何提示,用户只能干瞪眼。
  • 做过哪些尝试:第一反应是在进入详情页前先做校验,但详情页的入口不止一个(首页卡片、搜索结果、我的发布、成功页跳转),在每个入口都拦一道迟早会漏。最后决定把兜底做在详情页自己内部——无论从哪进来,读不到数据就显示统一的空态。
  • 是否解决:解决。详情页渲染前先查数据,查不到就展示「这条信息可能已被删除或不存在」的提示页,并提供返回首页的入口,单独提交了一个 commit。
  • 有何收获:异常分支的兜底应该收在「最后一道关卡」而不是分散在各个入口——入口会越来越多,关卡只有一个。这也呼应了老师提的「不能只按一条顺利成功的路线设计产品」。

问题三:从详情页返回,搜索条件和滚动位置全丢了

  • 问题描述:在搜索页筛选了半天,点进某条详情,再返回时关键词、分类、「只看进行中」开关全部被重置,列表还跳回了顶部——真实使用中这是非常恼人的体验。
  • 做过哪些尝试:想过把筛选条件缓存到 localStorage,但这会让「刷新页面」和「返回上一页」两种场景行为不一致,过度设计了。最后选择在页面状态里记录「从哪个页面、带着什么条件、滚动到哪里」进入详情的,返回时原样恢复。
  • 是否解决:解决。进入详情时携带来源信息,返回时还原筛选条件和滚动偏移,搜索页、我的发布两个入口都生效。
  • 有何收获:页面级状态管理里,「从哪里来」和「是什么」同样重要。导航不是简单的页面切换,而是带着上下文的往返。

结对协作方面:最大的经验是「约定先行」——数据模型和 Core 函数签名先写进 docs/接口约定.md,两个人再各自开发,我第二阶段接入第一阶段的存储层时没有任何理解偏差。版本管理上坚持功能块提交 + PR review,main 分支始终保持可运行状态。


九、评价我的队友

值得学习的地方:张锦昊同学的工程规划能力让我印象深刻。项目启动前他就定好了「规则—存储—页面」三层分离的架构和数据模型,并先写好了接口约定文档,我第二阶段开发时照着文档对接,几乎没有因为理解偏差返工。他的 commit 节奏也很值得学习——每个功能块独立提交、随时可以运行,我 fork 之后能很清楚地按提交历史读懂项目的演进过程。此外他主动承担了单元测试框架的选型和编写,31 个用例覆盖了很多我没想到的边界情况(比如 localStorage 损坏、写入失败回滚)。

需要改进的地方:前期架构设计投入的时间偏多,一定程度上压缩了后期的联调和走查时间,导致个别界面细节到测试中后段才暴露。另外他在任务拆分上可以更早地把接口文档同步给我,让我并行开始搜索页的开发,而不是等第一阶段完全结束。总体来说是一次高效且愉快的合作。

posted @ 2026-10-08 23:15  zkx34  阅读(8)  评论(0)    收藏  举报