2026秋软件工程结对作业(第二次之程序实现)
第二次结对作业:校园失物招领小程序(程序实现)
1. 项目与链接
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | H202601软件工程与软件工程实践 |
| 这个作业要求在哪里 | 2026秋软件工程结对作业(第二次) |
| 这个作业的目标 | 将校园失物招领原型实现为可运行的 Web 应用,完成核心业务流程,并通过 GitHub 协作、PSP 和单元测试实践结对开发、版本管理与软件测试 |
| 我的博客/本作业链接 | 102401425 薛昌建/软件工程结对作业(第二次) |
| 队友的博客/本作业链接 | 102401426 薛强龙/软件工程结对作业(第二次) |
| GitHub 仓库 | 102401425-102401426 |
2. 具体分工
| 成员 | 分工 |
|---|---|
| 薛昌建 | 仓库创建与合并(3 个 PR)、页面结构与 Figma 外观(index.html/assets/style.css)、发布与首页列表初版、toast 通知、继续发布清表单、assets 搬目录 |
| 薛强龙 | 共享服务与发布者权限(server.js/assets/core.js)、筛选与我的发布、状态同步与复制、超时/空结果/返回保留/日期时间/类别选项等功能与修复、46 项测试、README/TESTING 文档 |
3. PSP表格
| PSP2.1 阶段 | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|
| Planning 计划(小计) | 15 | 10 |
| Estimate 估计任务时间 | 15 | 10 |
| Development 开发(小计) | 675 | 880 |
| Analysis 需求分析(含学新技术) | 60 | 90 |
| Design Spec 生成设计文档 | 40 | 30 |
| Design Review 设计复审 | 20 | 15 |
| Coding Standard 代码规范 | 15 | 10 |
| Design 具体设计 | 60 | 45 |
| Coding 具体编码 | 300 | 420 |
| Code Review 代码复审 | 60 | 90 |
| Test 测试(自测改代码提修改) | 120 | 180 |
| Reporting 报告(小计) | 105 | 130 |
| Test Report 测试报告 | 60 | 80 |
| Size Measurement 计算工作量 | 15 | 10 |
| Postmortem & Process Improvement Plan 事后总结与改进 | 30 | 40 |
| 合计 | 795 | 1020 |
以上小计和合计按子项相加,一级阶段与子项不重复计时。
编码和测试超得最多:权限校验、超时、空结果这些返工了三次,测试从 29 加到 46。需求分析也超了,Figma 对外观花的时间比想的多。感觉下次编码前可以先把接口定死,少返工。
4. 解题思路与设计实现
4.1 代码实现思路(文字描述)
这里我们选择 WEB 来进行实现,不用框架也不装后端依赖。页面通过 index.html 里面的 7 块 section 进行切换,点一下显示一块,靠 show(id) 把其他块藏起来。样式在 assets/style.css,按照 Figma 设计来实现的深蓝 hero、寻物蓝招领绿 tab、白卡片。
数据分两路:直接运行 html 就是走 localStorage,只存自己浏览器;如果用 node server.js 就走 /api,大家共用 data/items.json。读写逻辑放到 assets/core.js,纯函数,浏览器和 Node 都能用,测试直接测它。
主逻辑分为六步:发布(doPublish 先校验再存储)→首页列表(renderList 按寻物/招领 tab 过滤)→搜索(filterItems 多关键词 AND,再叠加类型/状态/类别/地点)→详情(openDetail 按 id 取)→联系(doContact 跳转联系页)→标明状态(markDone 只给原发布者点,寻物变已找到、招领变已归还)。状态改变后首页、搜索、详情三处一起变,不留下旧数据。
4.2 关键实现流程图 / 数据流图
主链分三条线:查看与联系、发布、更新状态。
查看
首页 → 浏览/搜索 → 查看详情 → 联系发布者 → 一键复制。
发布
发布:首页 → 选寻物或招领 → 填信息 → 缺项拦住 → 发布成功 → 回首页。
状态更新
更新状态:详情 → 只有原发布者能点 → 标记已找到/已归还 → 状态页 → 列表三处同步变灰标。
4.3 重要代码片段与解释
发布这里会先进行校验,如果填的不对就直接拦下来,输入框里面已经填的字会留着:
var error = Core.validatePublish(data); if (error) { notify(error); return null; }
validatePublish 在 assets/core.js 里面,它会把四个必填项逐个检查是否为空,类型只认寻物和招领,超出长度的按照 limits 拦下来。页面输入框也设置了长度,前端调用这个函数验证一遍,共享模式下服务器会再验证一遍,所以只改 HTML 是绕不过去的。本地模式的数据存在自己的浏览器里面。
function validatePublish(data) {
if (!data || typeof data !== 'object') return '发布内容无效';
if (data.type !== '寻物' && data.type !== '招领') return '请选择寻物或招领';
var labels = { name: '物品名称', place: '地点', time: '时间', contact: '联系方式' };
for (var key of Object.keys(limits)) {
if (data[key] !== undefined && typeof data[key] !== 'string') return '填写内容必须是文字';
var text = (data[key] || '').trim();
if (labels[key] && !text) return '请填写' + labels[key];
if (text.length > limits[key]) return key + ' 超过长度限制(' + limits[key] + ' 字)';
}
return null;
}
只有发布成功的时候才会清空表单,如果失败了输入会保留下来,首页也会按照实际发布的类型进行切换:
Object.keys(Core.limits).forEach(function (key) { el('f-' + key).value = ''; });
homeTab = published.type; updateTabs(); refreshViews(); notify(''); show('page-success'); return published;
} catch (error) {
notify(error.name === 'RequestTimeoutError'
? '发布结果未确认:' + error.message + '。输入已保留,请先刷新列表核对是否已发布,避免重复提交。'
: '发布失败:' + error.message + '。输入已保留,请重试。');
return null;
}
这是 doPublish 里的一段逻辑。超时只是客户端不等了,服务端可能已经存上,所以不会自动重发,要先刷新核对之后再决定。
搜索使用多关键词 AND 的方式,会把名称、地点、特征、类别放在一起扫描:
var hay = [item.name, item.place, item.feature, item.category].join(' ').toLowerCase();
return keys.every(function (key) { return hay.includes(key); }) &&
(!options.type || options.type === '全部' || item.type === options.type) &&
(!options.status || options.status === '全部' || (options.status === '进行中' && !isDone(item)) || (options.status === '已处理' && isDone(item))) &&
(!options.category || item.category === options.category) &&
(!options.place || item.place === options.place) &&
(!options.mine || item.isOwner === true);
输入"水杯 图书馆"这两个词都要命中,英文不区分大小写,再叠加类型、状态、类别、地点这四组筛选,结果按照新到旧来排序:
function newest(items) { return items.slice().sort(function (a, b) { return b.createdAt - a.createdAt; }); }
更新状态只认原发布者,前端会把按钮藏起来,后端还要再检查一遍:
if (!item || !owns(item)) { notify('只有原发布者可以更新状态'); return false; }
owns(item) 主要看两点:是不是访客预览,以及 item.isOwner 是不是 true。本地模式拿 ownerId 和本地 token 来比较,生成 isOwner;共享模式下服务器按照请求头里面的 token 来计算归属,更新的时候还会再验证一遍,伪造字段是拿不到权限的。标记完成之后三处会一起刷新,如果详情、搜索、存储对不上测试就会挂:
refreshViews(); el('done-title').textContent = '已标记为' + status; notify(''); show('page-done'); return true;
用户输入全部走 textContent,不会拼成 HTML,就算传入 <img onerror> 也只会当成字面文字:
function card(item) {
var node = makeNode('button', 'card'); node.type = 'button';
node.appendChild(makeNode('div', 'thumb' + (item.type === '寻物' ? ' orange' : ''), '物'));
// 此处省略其他卡片节点的创建代码。
main.appendChild(makeNode('div', 'card-name', item.name));
4.4 实现成果展示
1.首页

2. 发布页

3. 搜索

4. 详情

5. 标记状态

5. 附加特点设计与展示
5.1 创意与意义
这里做了三件小事,都是自己找东西的时候想要的功能。1. 类型加状态加类别加地点这四组筛选。2. 一键复制联系方式,联系方式手动复制比较麻烦,所以做成一个按钮,点一下就行。3. "只看我的发布"把列表收在自己发过的信息上,回头改状态的时候不用在全部信息里找;访客预览会把标记按钮藏起来,点开就能看到别人打开这条信息是什么样子。
5.2 实现思路
筛选都放在 Core.filterItems 里面,关键词先分词再用 AND 连接,四个维度各自判断,最后取交集。复制先走 navigator.clipboard,如果没有或者被拒绝就降级到 execCommand,实在不行就提示手动选择,不会虚报成功。
5.3 关键代码与解释
筛选
筛选的核心在 Core.filterItems,关键词和四组条件都在这一个函数里判断:
function filterItems(items, options) {
var keys = (options.keyword || "")
.trim()
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
return newest(
items.filter(function (item) {
var hay = [item.name, item.place, item.feature, item.category]
.join(" ")
.toLowerCase();
return (
keys.every(function (key) {
return hay.includes(key);
}) &&
(!options.type ||
options.type === "全部" ||
item.type === options.type) &&
(!options.status ||
options.status === "全部" ||
(options.status === "进行中" && !isDone(item)) ||
(options.status === "已处理" && isDone(item))) &&
(!options.category || item.category === options.category) &&
(!options.place || item.place === options.place) &&
(!options.mine || item.isOwner === true)
);
}),
);
}
关键词按空格切开,每个词都要在 hay 里出现(keys.every),这就是 AND 的意思。hay 是名称、地点、特征、类别拼成的一整串,所以"图书馆"写在哪个字段里都能命中;英文统一转小写,Lost 和 lost 等价。
后面五个条件都是"没选就放行":!options.type 是没选,"全部" 是选了全部,两种情况都直接过,只有选了具体值才比对。状态存的是"已找到/已归还",界面上只有"进行中/已处理",所以用 isDone(item) 换算。options.mine 打开时只留自己发布的。
最后 newest 按 createdAt 从新到旧排,filter 返回新数组,不会改到源数据。
一键复制
复制这里要等真正写进去才报成功:
async function copyContact() {
var text = el('contact-text').textContent;
try {
if (navigator.clipboard && navigator.clipboard.writeText) await navigator.clipboard.writeText(text);
else {
var field = makeNode('textarea', 'copy-fallback'); field.value = text; document.body.appendChild(field);
try { field.select(); if (!document.execCommand('copy')) throw new Error('复制不可用'); }
finally { field.remove(); }
}
notify('联系方式已复制'); return true;
} catch (error) { notify('自动复制失败,请选中联系方式手动复制'); return false; }
}
普通的通知 2.5 秒会自己消失。读取失败、存储不可用这些会用 sticky 常驻下来;校验错误、复制失败这些没有传 sticky,还是会自动消失。
5.4 实现成果展示
1. 四组筛选

2. 一键复制

3. 只看我的和访客预览


6. 目录说明与使用说明
6.1 目录组织
102401425-102401426/
├── index.html 页面入口,双击用 Chrome 打开
├── assets/
│ ├── style.css 页面样式
│ ├── core.js 前后端共用的数据校验、恢复与筛选纯函数
│ └── app.js 交互、身份、存储、搜索与状态刷新
├── server.js 共享服务;提供多用户数据共享与服务端权限校验
├── package.json 启动和测试命令,无第三方依赖
├── README.md 运行与使用说明
├── TESTING.md 白盒测试设计与验收说明
├── tests/
│ ├── core.test.js 校验、边界与组合筛选测试
│ ├── app.test.js 前端回归测试(模拟 DOM 和浏览器存储)
│ └── server.test.js HTTP、权限、持久化与故障测试
└── data/items.json 共享服务首次发布后生成;不会提交到 Git
index.html 是整个项目的入口,assets 里面放样式和逻辑,server.js 用来提供共享模式,tests 里面的用例可以直接跑,data 是本地生成不会上传。
6.2 运行方法
本地演示
用 Chrome 双击打开 index.html,无需 Node。该模式的数据只存当前浏览器,不与其他用户或共享模式同步,不能替代多用户共享功能的验收。
共享模式
安装 Node.js 22 或以上版本,在仓库目录运行以下命令,无需安装第三方依赖:
node server.js
用 Chrome 打开 http://127.0.0.1:3000/。普通窗口和无痕窗口分别作为两个发布者身份:普通窗口发布一条寻物信息,无痕窗口刷新查看;由原发布者标记“已找到”,另一窗口刷新或等待同步后查看状态变化,并确认不能修改他人的信息。再发布一条招领信息,验证搜索、详情、联系及“已归还”流程。
7. 单元测试
7.1 测试工具与学习
工具只用 Node 自带的 node:test 和 node:assert/strict,没有安装额外工具。assets/core.js 写成了纯函数,浏览器和 Node 都可以直接用;assets/app.js 用 node:vm 来运行线上同一份代码,DOM 只是 mock 的;server.js 用内置的 fetch 来打真接口。
学习过程:
- 先看参考:邹欣的"单元测试和回归测试"来初步了解,廖雪峰的教程补充断言写法,Mocha 的教程借鉴组织方式,用内置的 test 来代替。
- 再定分支:打开 core.js,把每个 if 和三元的出口列成表,一个出口对应一条用例。
- 后补输入:成功断言返回值,失败主要看三样,报错、状态不变、原数据不覆盖;app.js 还要查详情、搜索、存储这三处一致。
简易教程:5 分钟跑起你的第一个断言
- 在仓库中新建
tests/hello.test.js,写入以下内容:
const test = require('node:test');
const assert = require('node:assert/strict');
const Core = require('../assets/core');
test('空白联系方式不能发布', () => {
const input = { type: '寻物', name: '水杯', place: '图书馆', time: '今天', contact: ' ' };
assert.ok(Core.validatePublish(input)); // 有错误信息即拦住了
});
- 在仓库根目录运行,不用安装依赖:
node --test tests/hello.test.js # 只跑这一条
node --test tests/*.test.js # 全跑
npm test # 同全跑
- 看结果:
pass 1 fail 0就是通过了;退出码非零说明挂了,顺着报错的行号去找被测函数。 - 加用例的时候照着这个格式抄:一个条件一个 test,名字写清"输入→预期",比如"名称81字拒绝"。改完代码要重跑全量,免得改 A 坏 B。
7.2 测试代码与被测函数
测试分三层来测:assets/core.js 纯函数(校验/过滤/解析),assets/app.js 页面逻辑(node:vm 运行真实代码,DOM/存储/剪贴板都是 mock 的),server.js 接口(内置 fetch 打真实 HTTP)。46 项全部通过,core 13 项,app 27 项,server 6 项。跑多久要看机器,不拿耗时当通过标准。
以校验为例,空白联系方式必须拦住:
const test = require('node:test');
const assert = require('node:assert/strict');
const Core = require('../assets/core');
test('空白联系方式不能发布', () => {
const input = { type: '寻物', name: '水杯', place: '图书馆', time: '今天', contact: ' ' };
assert.ok(Core.validatePublish(input));
});
再比如状态流转这块,发布者标记完成之后,详情、搜索、持久化这三处要对得上。下面这段在 tests/app.test.js 里面运行,依赖里面的 boot 来搭环境和 base 来造数据:
test('状态更新后详情、搜索筛选及持久化保持一致', async () => {
const b = await boot({ raw: JSON.stringify([base]) });
b.c.searchStatus = '进行中';
b.c.doSearch();
assert.equal(b.el('search-result').children.length, 1);
b.c.openDetail('old');
assert.equal(await b.c.markDone(), true);
b.c.show('page-detail');
assert.equal(b.el('d-status').textContent, '已找到');
b.c.show('page-search');
assert.equal(b.el('search-result').children.length, 0);
assert.equal(JSON.parse(b.store.lost_items)[0].status, '已找到');
assert.equal(b.el('btn-done').disabled, true);
});
剩下的用例都在仓库的 tests/ 里面:core.test.js 负责纯函数分支,app.test.js 负责发布/权限/复制/多标签同步,server.test.js 负责双身份共享、越权拒绝、伪造字段忽略。
7.3 测试数据思路与刁难应对
数据按照白盒分支来构造:正常来说一条能走通就可以(水杯/图书馆/今天/联系方式齐全),边界一批(名称 80 字通过、81 字拦下,空关键词查全部,多关键词 AND),异常一批(四个必填逐个留白,类型乱填,JSON 写坏,ID 重复,状态和类型对不上)。
刁难这里想了五种:① 拿 <img onerror> 当名称传进来,页面全部走 textContent,只会当成字面文字画出来;② 拿别人的 id 去调状态接口,服务端按 token 算出 ownerId,对不上直接返回 403;③ 伪造 owner/status/id 这三个字段,服务端不认客户端传的,只用自己生成的;④ 浏览器不给剪贴板权限,不硬说成功,提示手动复制;⑤ 存储满了或者文件坏了,报错但不动原数据,输入框里面的字也留着。
说实话这里覆盖得并不全:mock 的 DOM 不验证真实布局,file:// 存取、局域网防火墙、大并发都没有测。模拟的代替不了真机,这些要按 TESTING.md 第 4 节的清单,用 Chrome 手工点一遍。
8. GitHub签入记录截图
1. commit 记录

2. PR 记录

9. 异常与结对困难
问题描述
遇到过三件事。一是"继续发布"点完之后表单还留着上次的字,连发两条会把旧内容带进去;二是把静态文件都搬到 assets 目录之后服务端白名单和测试路径没有同步,静态资源返回 404,46 项里面有好几项读不到文件;三是 notice 提示常驻不消失,复制一次"已复制"就一直挡着。
做过哪些尝试
加了 clearPublishForm,点继续发布的时候先清空再跳页,发布成功那条路本来就是清空的,现在两边对齐了。搬目录用 git mv 保留历史,index 的三处引用、server 的白名单和 require、两个测试文件的 require 全部改成 assets 路径。notice 加了 sticky 参数,成功走 2.5 秒自动消失,读取失败、存储不可用走常驻,样式改成底部浮动深色,测试 mock 把 2.5 秒放行。改完之后 46 项重跑一遍。
是否解决
修完之后的状况是:连发两条时第二条是空表单,46 项全部通过,服务端 6 个地址都返回 200,复制提示 2.5 秒会自己消失。真机上连续点击和资源加载都手工验证一遍保证没有问题。
有何收获
成功页那条路清清理了,并不代表所有入口都清理,继续发布这种第二入口最容易漏。搬目录看着简单,引用、服务端、测试这三处要一起改,漏掉一处就是 404。提示也要分两级,成功的自己消失,报错的留下来,不然会一直挡着。
10. 队友评价
值得学习的地方
薛强龙的提交拆得很细,13 条基本是一条一个功能或者一个修复,信息也写清楚了改的什么,比如"fix: 详情返回原列表并保留筛选和滚动位置",光看标题就知道动的是哪一处,不用点进去翻代码。共享服务和发布者权限这块是他先搭起来的,归属按请求头里的 token 算,客户端传过来的 owner 字段一律不认,后面我做页面的时候直接按这套接口接就行,省了很多来回确认。单元测试也是他一个人从 29 项补到 46 项,core、app、server 三层都覆盖到了,还顺手把 TESTING.md 里的手工验收清单补齐。空结果分原因、请求超时、必填标记和字数限制这几处,题目里并没有要求,是他自己想到加上的。
需要改进的地方
早期有一条提交(1e12709)把状态同步、输入渲染、复制反馈、筛选和我的发布五件事并在一起,后面出问题定位的时候不太好找,如果一开始就按一件事一条来拆,回滚会方便很多。另外第三个 PR 一次带了 9 条提交,新功能和各种修复混在一起提,验收的时候要一口气全过一遍,分成两三次提交会稳一点。
浙公网安备 33010602011771号