2026秋软件工程结对作业(第二次之程序实现)|拾见校园
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | H202601 软件工程与软件工程实践 |
| 这个作业要求在哪里 | 结对作业第二次:程序实现 |
| 本作业博客 | 本篇作业博文 |
| 这个作业的目标 | 把已有的失物招领原型实现为可运行程序,走通发布、查找、联系和处理完成的流程,并用测试检查规则和异常情况 |
| 本人 | 姚华俊,082403146 |
| 结对同学 | 黄锦培,102401221;同学本次作业博文;GitHub |
| 项目仓库 | Grim761/082403146-102401221 |
| 同学的 fork / PR | coderbrain404/082403146-102401221 · 测试提交 4e9d0c2 · PR #1 |
| 在线体验 | 拾见校园程序实现版(本机保存模式) |
| 上一次原型 | 拾见校园交互原型 |
一、从原型到程序,这次补上什么
上一次作业把“拾见校园”改成了校园线索墙:每件物品是一张卡片,先展示类别、地点、时间和状态,再进入详情核对。我希望这次实现能够保留这个方向。失物招领页面最有用的内容,往往是“东西在哪里”“什么时候发生”“还需要联系吗”,这些信息应该比装饰更容易被看到。
原型里的按钮可以跳转,但点击成功并不代表数据保存成功。比如,刚发布的信息刷新后消失,或者同学已经领回物品,列表仍显示“招领中”,页面再完整也不能把事情处理完。这次程序实现重点补上了三件事:数据保存、发布者归属、各个页面对同一状态的展示。
实现采用 HTML、CSS、JavaScript,另外提供一个不依赖第三方库的 Node 共享服务。选择这套方式是为了让下载后的页面容易运行,也方便把业务函数单独拿出来测试。范围仍然围绕校园失物招领,没有加入聊天、地图、实名认证和后台管理。

二、已经实现的使用流程
1. 发布寻物或招领
发布者先选择“我丢东西了”或“我捡到东西了”,再填写物品名称、类别、遗失或拾取时间、区域、具体地点和联系或领取信息。补充描述和图片可选。名称、地点和联系信息不能只填空格,时间不能明显在未来。
表单右侧会同步出现一张线索卡。这个预览主要用来检查标题是否含糊、地点是否写得足够具体。未提交的文字会保存为草稿,刷新或重新进入发布页可以恢复;图片需要重新选择,避免大图草稿占满浏览器存储。保存成功后进入对应详情,而不是只弹出一句“发布成功”。

2. 浏览、搜索和查看详情
首页默认显示仍在处理的线索,可以切换寻物、招领,也可以按物品类别和校园区域共同筛选。搜索会检查名称、描述和地点等字段。输入“图书馆 钥匙”时,两段关键词需要同时匹配,能比只搜“钥匙”更快缩小范围。
详情页把发生时间、区域、具体地点和当前状态放在一起。联系信息可以直接复制;招领信息也可以只写公共领取地点,例如“已交至图书馆一楼服务台”。页面提醒核对独有特征,描述中不宜公开完整证件号码。

3. 由发布者更新处理结果
寻物和招领使用同一套底层状态:open 表示仍在处理,resolved 表示已经完成。显示时再结合类型生成不同文字:寻物完成显示“已找到”,招领完成显示“已归还”。
发布者可以在详情页或“我的发布”更新状态。完成后,默认列表收起这条线索,详情页收起联系信息;勾选“包含已完成”可以查看历史。更新前有确认提示,误操作时也能重新开放。共享服务会检查当前账号是否为发布者,不能只靠页面隐藏按钮来限制权限。

收起联系方式只是界面行为,原字段仍保存在数据中,并不等于已经删除公开过的信息。
三、结对安排与 PSP
1. 分工安排
| 成员 | 开发计划中的分工 | 可从仓库核对的执行记录 |
|---|---|---|
| 姚华俊 | 页面和交互流程、发布表单、即时预览、运行说明与博文整理 | Grim761 账号在主仓库按业务规则、共享服务、页面流程、文档和测试分阶段提交;线下工作时长不能仅由提交记录推断。 |
| 黄锦培 | 搜索筛选和状态规则复核、边界测试、共享流程检查、README 复核 | coderbrain404 账号在个人 fork 补两项服务端文件存储回归测试,更新测试入口、README 和报告,创建 PR #1;本地 43 项测试通过。 |
当前主仓库已有分阶段的功能提交;黄锦培的 fork 分支 codex/store-recovery-tests 已推送,并创建了指向 Grim761:main 的 PR #1。PR 已于 2026-10-10 22:33(北京时间)合并到主仓库 main,合并提交为 669c4b8。
2. PSP 表
编码前在项目的开发计划中记录了本轮工作预估,单位为分钟。实际列是根据完成的工作回顾估算的个人投入时间,不是计时器记录;AI 辅助生成代码和文档的运行时间不单独计入。Development、Reporting 是小计,不重复计入合计。
| PSP2.1 | 阶段 | 预估耗时 | 本人耗时(回顾估算) |
|---|---|---|---|
| Planning | 计划 | 15 | 10 |
| · Estimate | 估计本次任务需要多少时间 | 15 | 10 |
| Development | 开发 | 405 | 325 |
| · Analysis | 需求分析与技术学习 | 40 | 35 |
| · Design Spec | 生成设计文档 | 25 | 20 |
| · Design Review | 设计复审 | 15 | 15 |
| · Coding Standard | 代码规范 | 10 | 5 |
| · Design | 具体设计 | 35 | 30 |
| · Coding | 具体编码 | 180 | 110 |
| · Code Review | 代码复审 | 25 | 20 |
| · Test | 测试与修复 | 75 | 90 |
| Reporting | 报告 | 60 | 60 |
| · Test Report | 测试报告 | 25 | 30 |
| · Size Measurement | 工作量统计 | 10 | 5 |
| · Postmortem & Process Improvement Plan | 事后总结与改进计划 | 25 | 25 |
| 合计 | 480 | 395 分钟(约 6 小时 35 分) |
与预估相比,AI 辅助让页面与服务代码初稿更快形成;测试与修复反而比预估多用了时间,因为需要核对草稿跳转、存储失败、发布者权限和跨账号流程。上述数字是回顾估算,缺少逐阶段实时计时记录。下一次在任务开始和切换时记时,才能更准确地分析偏差。
四、程序怎样组织
1. 把页面和业务规则分开
页面入口是 index.html,app.js 处理页面渲染、表单和弹窗;core.js 放字段校验、搜索和状态更新等规则;storage.js 负责本机保存;api.js 负责识别运行模式和请求共享接口;illustrations.js 负责物品示意图和类别匹配。共享服务再分成 HTTP 接口与文件存储两个模块。
这样划分之后,搜索是否匹配、其他人能否修改状态,不需要通过点击页面才能检查。相同的规则既能被浏览器调用,也能被 Node 测试和服务端调用。

2. 每条线索的数据
一条记录除了物品信息,还需要保存发布者、状态以及两个不同用途的时间。eventAt 是遗失或拾取时间,createdAt 是发布时间,updatedAt 记录最近一次状态变化。这三个时间不能混为一谈:前者帮助核对事件,后两者帮助判断信息的新旧和处理进度。
{
id, ownerId, ownerName,
kind, status,
title, category, zone, place,
eventAt, description, contact, photo,
createdAt, updatedAt, demo
}
demo 用来区分初始示例和新发布的信息。共享模式中的 ownerId 由服务端根据登录会话确定,客户端填写一个别人的 ID 并不能改变线索归属。
3. 发布、查看和状态更新的连接

发布时先校验输入,再保存数据,保存确认成功后才进入详情。状态更新也一样:检查归属和目标状态,保存成功后重新读取列表,再让线索墙、详情和“我的发布”显示同一个结果。联系和交接在线下完成,程序负责把线索和处理状态记录清楚。
五、几段值得说明的代码
1. 同一函数检查状态与发布者
changeStatus 中,缺失记录、归属不符和非法状态分别走不同失败分支。已完成的记录再次设为已完成,不会重复制造更新时间;真正变化时返回新对象,不修改原记录。
function changeStatus(post, actorId, nextStatus, now = Date.now()) {
if (!post) throw new DomainError('这条线索不存在或已被移除。', 'NOT_FOUND', 404);
if (!actorId || post.ownerId !== actorId)
throw new DomainError('只有发布者可以修改这条线索。', 'FORBIDDEN', 403);
if (!['open', 'resolved'].includes(nextStatus))
throw new DomainError('状态参数无效。');
if (post.status === nextStatus) return { ...post };
return { ...post, status: nextStatus, updatedAt: new Date(now).toISOString() };
}
页面根据当前身份决定是否显示按钮,服务端仍然再次调用这段规则。前者让页面容易使用,后者负责拒绝越权请求。
2. 保存成功之后才替换内存中的数据
本机存储可能因为配额不足而失败。如果先修改内存,再写浏览器存储,页面就可能显示一条没有真正保存的记录。因此 commit 先执行 setItem,成功之后才替换 state。
function commit(next) {
if (persistent) {
try { storage.setItem(KEY, JSON.stringify(next)); }
catch (_) {
throw new Core.DomainError(
'本机存储空间不足,信息尚未保存。请缩小图片或导出旧记录。',
'STORAGE_FULL', 507
);
}
}
state = next;
}
测试把 setItem 改为抛出配额错误,确认发布失败后旧列表仍然不变。共享存储使用相似的顺序:写临时文件、重命名替换、再更新内存快照。它只用于一个服务进程,不能当作多实例数据库。
3. 发布成功后避免旧草稿再次写回
草稿会在离开发布页时保存,但成功发布属于另一个场景:这时旧草稿应该清掉。实现中为成功发布设置一次性的 skipDraftSave,hashchange 时跳过旧表单保存,随后恢复普通行为。
window.addEventListener('hashchange', () => {
clearTimeout(draftTimer);
if (!state.skipDraftSave) saveDraft();
state.skipDraftSave = false;
void route();
});
这个细节涉及事件顺序。只在提交按钮里清空草稿还不够,后续跳转事件也可能把旧内容重新保存。
六、两个实用的额外功能
1. 按类别、区域和关键词共同筛选
校园中相似物品很多。“校园卡”会匹配很多线索,但“图书馆+证件卡片+招领”更接近具体需求。因此筛选条件共同生效,并且默认只看未完成记录,避免旧信息干扰。
关键词先去掉首尾空格、转为小写,再按空格拆分。每个词都需要在名称、描述或地点等字段中出现:
const words = text(filters.query).toLocaleLowerCase().split(/\s+/).filter(Boolean);
const haystack = [post.title, post.description, post.zone, post.place, post.category]
.join(' ').toLocaleLowerCase();
return words.every(word => haystack.includes(word));
类别和区域各自还有严格匹配的判断。自动测试用两个都带 USB 的记录验证大小写和多关键词;组合筛选用九条示例检查“招领+图书馆+证件卡片+处理中”只返回预期的一条。
2. 复制联系或领取信息
同学查看详情后,经常需要把领取地点发给自己或保存下来。用复制按钮可以减少选中文字的操作,手机上也更方便。
实现优先调用浏览器剪贴板 API:
if (!navigator.clipboard?.writeText) throw new Error('FALLBACK');
await navigator.clipboard.writeText(post.contact);
API 不可用或调用失败时,会尝试通过临时文本框复制;两种方式都失败才提示手动选取,不会直接显示复制成功。实际浏览器检查中,复制后的文本与详情里的领取信息一致。完成线索不再显示复制按钮,减少后续重复联系。
七、界面保留自己的特点
这次仍然使用纸色背景、深蓝标题和陶土色提示,线索卡上的编号、文字类别标记、地点和时间形成固定位置。寻物用蓝色,招领用陶土色,完成状态再用偏绿的提示。状态除了颜色,还直接写出文字,避免只靠颜色判断。
字体使用系统自带字体。没有上传照片时,卡片会根据物品名称和类别显示钥匙、雨伞、耳机、校园卡等 SVG 物品示意图;上传照片后优先显示照片。详情页会注明“物品示意图”,避免把示意图当成实拍照片。页面没有依赖外部字体库。桌面列表是三列,窄屏变成一列;发布页在桌面把表单和预览并排,手机上上下排列。内置浏览器中检查了 1440px 和 390px 两个断点,均未出现横向溢出。
我更希望它像一面可查找、可更新的校园信息墙。卡片不需要放很多按钮,先把信息看清楚,再到详情完成下一步操作。
八、测试工具、用例与结果
1. 怎样运行自动化测试
使用 Node 内置的 node:test 和 node:assert/strict,不需要额外安装测试框架。在项目根目录运行:
npm test
node --test --experimental-test-coverage tests/core.test.js tests/storage.test.js tests/store.test.js tests/server.test.js
test 定义一项测试,assert.equal 检查单个结果,assert.deepEqual 比较列表,assert.throws 和 assert.rejects 检查同步、异步失败。接口测试启动临时端口,使用临时目录,结束后关闭服务,不会改动实际运行的数据。
例如,修改权限的测试直接检查 changeStatus 的失败分支:
test('其他用户与未登录用户:不能修改他人的发布', () => {
for (const actor of ['student-b', '', null]) {
assert.throws(
() => C.changeStatus(post(), actor, 'resolved'),
e => e.code === 'FORBIDDEN'
);
}
});
2. 数据怎样设计
测试并非只准备一组能正常发布的数据。先阅读函数中的判断,再选择成功、失败和边界输入,特别检查会影响保存或权限的地方。
| 重点用例 | 数据设计 | 预期 |
|---|---|---|
| 有效发布 | 完整的寻物 / 招领字段 | 保存记录、生成正确初始状态 |
| 必填与空格 | 逐项置空;名称、地点和联系信息只填空格 | 返回对应字段错误 |
| 标题边界 | 40 个字、41 个字 | 前者通过,后者拒绝 |
| 事件时间 | 无法解析的日期、明显在未来的时间 | 拒绝提交 |
| 多词与组合筛选 | usb 黑色;类型+类别+区域+状态 | 所有条件共同满足 |
| 发布者权限 | 本人、其他人、未登录、记录缺失 | 本人成功,其余明确失败 |
| 保存失败 | 模拟浏览器配额错误 | 不新增一条未保存记录 |
| 刷新后的数据 | 重新创建存储对象 | 身份、发布和完成状态保留 |
附录列出 18 项业务白盒测试。另有 9 项本机存储测试、2 项服务端文件存储测试和 14 项接口与集成测试,完整输入设计可查看主仓库中的测试报告。
3. 已运行的结果与范围
合并后的版本在本地 Node v24.16.0 上运行结果为 43 项通过、0 项失败,其中 29 项是单元测试,14 项是接口与集成测试。业务、示例、本机存储与服务端模块合计行覆盖率为 94.72%、分支覆盖率 76.89%;这一统计不包含页面 DOM 脚本,不能理解成整个项目都已覆盖。完整命令、输入设计和结果见测试报告。
浏览器走查实际检查了空表单提示、文字草稿恢复、发布后清空草稿、联系信息复制、图片发布与刷新、寻物已找到、招领已归还,以及另一个账号没有更新他人记录的入口。
上述交互操作使用 Edge 和内置浏览器。本次另从个人 fork 下载完整 ZIP,解压后用 Chrome 的无界面模式直接打开 index.html,确认“本机保存”和线索墙;无结果搜索、发布进入详情、刷新后保留、本人标记已找到和已完成筛选均通过。查看本机模式截图。共享服务在两个独立 Chrome 配置中检查了 A 发布、B 查看、B 更新被拒绝(403),以及 A 更新后 B 刷新看到完成状态。Chrome 中的图片选择、剪贴板实际写入和日常操作体验仍需人工复核。


PR 合并提交 669c4b8 的 GitHub Actions 测试与静态页面发布均已成功;本地 43 项测试结果和覆盖范围见上文。
九、目录与运行说明
082403146-102401221/
├── index.html
├── css/style.css
├── js/
│ ├── core.js
│ ├── seed.js
│ ├── storage.js
│ ├── api.js
│ ├── illustrations.js
│ └── app.js
├── server/
│ ├── server.js
│ └── store.js
├── tests/
│ ├── core.test.js
│ ├── storage.test.js
│ ├── store.test.js
│ └── server.test.js
├── docs/
├── tools/draw_diagrams.py
├── .github/workflows/
├── README.md
└── package.json
1. 本机体验
从 GitHub 下载 ZIP,完整解压,用 Chrome 打开 index.html;css、js 文件夹要一起保留。页面显示“本机保存”时,记录属于当前浏览器,不会同步到其他设备。页脚可以导出本机备份。在线静态页面也是这个模式。
本地文件的存储行为由浏览器决定。如果禁用存储,会显示“临时模式”并说明关闭页面后丢失;需要固定来源时,可以使用在线页面或下面的本地服务。
2. 共享体验
安装 Node.js 22 或更新版本,在项目目录运行:
npm start
无需 npm install,因为服务没有第三方依赖。用 Chrome 打开 http://127.0.0.1:8787,右上角应显示“共享服务”。A 账号注册并发布,B 在另一浏览器或无痕窗口中查找,A 更新结果后,B 刷新即可看到变化。
账号和线索保存在 data/shijian.json,该目录不会上传 GitHub。重启服务后数据保留,登录会话需要重新建立。当前只支持单个服务进程,适合教学演示;还没有实时推送、公开生产部署或多实例支持。GitHub Pages 本身只提供静态页面,不能运行这个后端。
十、Git 提交和遇到的问题
1. 分阶段提交
主仓库当前按功能保存了以下提交,可在提交历史查看对应差异。
| 提交 | 主要内容 |
|---|---|
| 6fae3d5 | 业务校验、本机持久保存和单元测试 |
| 419cd82 | 共享服务、发布者权限与接口测试 |
| d1579b3 | 线索墙、发布、详情、我的发布与页面交互 |
| f0d9bdb | 明确测试入口,避免不同 shell 的通配符差异 |
| d2b9902 | 运行文档、白盒测试报告、图示与自动测试 / 部署流程 |
| 1e855fe | 修正损坏本机记录的备份,增加原始数据导出断言 |
| c053fa7 | 用钥匙、雨伞、耳机等物品示意图替换单字图标 |
| 742fec7 | 修正与作业仓库命名不一致的链接 |
| 4e9d0c2(个人 fork) | 补文件存储失败恢复测试,更新测试入口与报告 |
| 669c4b8 | 合并 PR #1,纳入文件存储测试、报告和走查截图 |

另一位同学的 fork、本次提交和 PR #1已经可查。上面的截图记录主仓库先前提交。

上图是 GitHub 上 4e9d0c2 提交页的实际截图。下面是 PR #1 开放时的历史截图;该 PR 后来已合并,结果可在 PR 页面核对。

合并后的 GitHub 页面显示 PR 已合并到主仓库,合并提交为 669c4b8,两项检查通过。合并截图保存在个人 fork 的 docs/images/。

2. 草稿清空了,为什么再次进入还会恢复
第一次处理是发布成功后调用 clearDraft(),看起来已经结束。但跳转详情会触发 hashchange,旧表单仍在页面上,离开页面时的草稿保存又把它写回。最终在成功发布时增加一次性跳过标志,并保留普通离开时的保存行为。复核方式是发布后再次进入发布页,确认标题为空、旧草稿没有恢复。
3. 两种保存方式不能让用户混淆
单纯使用浏览器存储可以解决刷新丢失,但不能让另一台设备自动看到同一条线索。程序因此提供共享服务,同时在页面右上角写出当前模式,并在首页说明数据范围。在线体验链接没有部署后端,就明确写成本机体验;完整的账号共享流程按 README 启动服务演示。
4. 按钮隐藏不能代替权限判断
如果只在前端隐藏“标记已归还”,别人仍可能直接请求接口。共享服务根据会话取得用户身份,再调用状态变更规则。接口测试专门注册第二个账号,尝试修改第一个账号的线索,确认返回 403;浏览器中也检查了第二个账号没有修改入口。
5. 导出备份也要检查是否包含待恢复的数据
本机记录损坏时,程序会保留原值并进入临时模式。复核发现,如果导出按钮只保存临时的示例数据,这份备份仍然无法用于检查原来的问题。后来在异常情况下附加 recoveryOriginalRaw,把无法解析的原始字符串一起导出;正常备份不会多出这个字段。存储测试同时检查原值没有被覆盖、导出包含原字符串。这个问题说明,错误提示中的每一步建议都应该有实际可用的操作对应。
十一、个人总结
作为大三计算机科学与技术专业的学生,我对这次题目的理解比“把原型里的页面做出来”更具体了一些。一个页面可以有发布按钮、成功提示和漂亮的卡片,但这些东西之间还需要由同一份数据连接。刷新后能不能找到刚才的记录,换个账号能不能看到处理结果,都是使用流程的一部分。
这次实现最值得整理成经验的是状态和归属。寻物完成叫“已找到”,招领完成叫“已归还”,两种文字看似只是显示差异,底层却需要统一的状态规则。列表、详情和“我的发布”如果各写一套判断,后续很容易有一个地方漏改。把状态规则放到独立模块,再让页面读取同一结果,理解起来也更清楚。
另一个值得注意的地方是错误提示。表单缺少地点时,用户需要知道该补哪个字段;保存空间不足时,用户需要知道本次没有保存;还未完成的文字则需要留住,不能因为一处错误让人重写整张表单。这些情况不会增加很多页面,却会直接影响程序是否顺手。草稿在跳转事件中被再次写回的问题,也提醒我检查操作之间的顺序,不能只看某一个函数是否执行过。
测试部分让我更在意断言的对象。只检查“没有报错”太宽泛,还应检查记录是否新增、原数据有没有改变、状态是否正确、错误是否对应预期分支。比如配额不足的测试同时检查失败和旧列表不变,权限测试检查另一个账号收到 403。这样才能说明程序为什么符合规则。
在界面上,我希望继续保留校园线索卡的特点,让时间和地点固定在容易扫到的位置。以后如果继续做这个项目,会先考虑编辑已有线索、明确的数据整理方式和更完整的账号恢复,再决定是否增加复杂功能。本轮已用 Chrome 检查完整 ZIP 的本机流程和共享双账号流程;Chrome 中的日常操作体验仍需本人复核,未逐项实时计时的个人耗时也只能回顾估算。
十二、对结对同学的评价
- 值得学习:黄锦培在个人 fork 中把文件损坏保护、事务失败后的恢复写成可重复运行的测试,并通过 PR 提供差异和测试结果。这种用失败路径验证数据不丢失的做法值得保留。
- 改进建议:后续可以更早提交小范围 PR,让主仓库维护者在实现期间就能审查边界用例;每次复核也应在 PR 中写下运行命令和结论。这里评价的是可见提交方式,线下合作细节不从仓库记录推断。
附录:业务模块的白盒用例
| 编号 | 对应判断或路径 | 测试数据与预期 |
|---|---|---|
| C01 | 有效寻物与 trim | 标题带首尾空格;规范化并显示寻找中 |
| C02 | 招领的初始标签 | found+open;显示招领中 |
| C03 | 七项必填校验 | 逐项置空;每项返回对应错误 |
| C04 | 空格输入校验 | 空格、换行、全角空格;不能当有效字段 |
| C05 | 标题上限 | 40 字通过、41 字拒绝 |
| C06 | 时间解析和未来判断 | 无效日期、晚一天;返回时间错误 |
| C07 | 图片格式与长度 | SVG、超长数据拒绝;允许格式通过格式门槛 |
| C08 | 多词共同匹配 | usb 黑色;仅返回同时匹配的记录 |
| C09 | 空搜索和无结果 | 空白返回全部;火星飞船返回空数组 |
| C10 | 条件组合 | found+图书馆+证件卡片+open;只有 sample-001 |
| C11 | 时间排序 | 返回降序,原数组顺序不变 |
| C12 | 合法状态转移 | 本人完成两类线索,再重新开放 |
| C13 | 归属失败 | 其他人、空身份、null;均拒绝 |
| C14 | 记录或状态无效 | 缺失记录 404;deleted 状态拒绝 |
| C15 | 重复完成 | 不改变 updatedAt |
| C16 | 输出转义 | img 标签和 onerror 引号转为文本 |
| C17 | 账号校验 | 账号规范化;无效账号组合拒绝 |
| C18 | 初始数据结构 | 九条示例全部通过结构检查 |
本机存储和接口测试的完整表格、代码均在仓库中。覆盖尚未包含真实磁盘写满、大量账号和所有页面异步情况,后续可以从这些边界继续补充。
参考资料
- Node.js:Test runner,用于学习内置测试框架与覆盖报告。
- MDN:localStorage,用于确认持久保存及本地文件行为的限制。
- MDN:Clipboard.writeText,用于查看剪贴板 API 的用法和环境限制。
- GitHub:Using custom workflows with GitHub Pages,用于部署静态体验页面。

浙公网安备 33010602011771号