软件工程第二次结对作业
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 构造测试数据的思路
以白盒方法为主,对着每个函数的分支和边界构造输入,站在「测试人员会怎么刁难」的角度穷举:
- 正常路径:合法表单、本人操作、存在的 ID——先保证基本盘正确。
- 边界值:空串、纯空格、正好 30 字、31 字的标题——专抓差一错误和
trim遗漏。 - 非法取值:
spaceship分类、「火星基地」地点、stolen类型——验证白名单不是摆设。 - 越权:拿别人的
ownerId去改状态——权限必须在规则层拦住,而不是只在界面上藏按钮。 - 存储异常:损坏 JSON、写入抛异常——这是用户真实会遇到的(手动改坏浏览器数据、隐私模式写入受限),也是最容易漏测的。
- 副作用检查:状态更新后原数组、原对象必须原封不动——保证纯函数性质,防止隐性耦合。
自我评价: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 损坏、写入失败回滚)。
需要改进的地方:前期架构设计投入的时间偏多,一定程度上压缩了后期的联调和走查时间,导致个别界面细节到测试中后段才暴露。另外他在任务拆分上可以更早地把接口文档同步给我,让我并行开始搜索页的开发,而不是等第一阶段完全结束。总体来说是一次高效且愉快的合作。

浙公网安备 33010602011771号