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 关键实现流程图 / 数据流图

主链分三条线:查看与联系、发布、更新状态。

查看

首页 → 浏览/搜索 → 查看详情 → 联系发布者 → 一键复制。

graph TD A[首页] --> B[浏览信息列表] A --> C[搜索物品] B --> D[查看物品详情] C --> E{有结果?} E -- 无,改关键词/清筛选 --> C E -- 有 --> D D --> F[联系发布者] F --> G[一键复制联系方式] D --> H{是原发布者?} H -- 否,按钮隐藏 --> D H -- 是 --> I[标记已找到/已归还]

发布

发布:首页 → 选寻物或招领 → 填信息 → 缺项拦住 → 发布成功 → 回首页。

graph TD A[首页] --> B[选择发布类型] B --> C[填写物品信息] C --> D{信息完整?} D -- 缺项,提示并保留输入 --> C D -- 完整 --> E[提交并保存信息] E --> F{保存结果} F -- 失败或超时 --> G[提示错误或结果未确认] G --> C F -- 成功 --> H[发布成功反馈] H --> I[返回首页]

状态更新

更新状态:详情 → 只有原发布者能点 → 标记已找到/已归还 → 状态页 → 列表三处同步变灰标。

graph TD A[物品详情页] --> B{是原发布者?} B -- 否,标记按钮隐藏 --> A B -- 是 --> C[点标记已找到/已归还] C --> D{保存结果} D -- 失败或超时 --> E[提示错误或结果未确认] E --> A D -- 成功 --> F[状态页反馈] F --> G[首页/搜索/详情三处同步变灰]

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.首页

img

2. 发布页

img

3. 搜索

img

4. 详情

img

5. 标记状态

img

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. 四组筛选

img

2. 一键复制

img

3. 只看我的和访客预览

img

img

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 分钟跑起你的第一个断言

  1. 在仓库中新建 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)); // 有错误信息即拦住了
});
  1. 在仓库根目录运行,不用安装依赖:
node --test tests/hello.test.js  # 只跑这一条
node --test tests/*.test.js      # 全跑
npm test                         # 同全跑
  1. 看结果:pass 1 fail 0 就是通过了;退出码非零说明挂了,顺着报错的行号去找被测函数。
  2. 加用例的时候照着这个格式抄:一个条件一个 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 记录

img

点击查看完整 commit 记录

2. PR 记录

img

点击查看完整 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 条提交,新功能和各种修复混在一起提,验收的时候要一口气全过一遍,分成两三次提交会稳一点。

posted @ 2026-10-08 20:51  SuCylinder  阅读(6)  评论(0)    收藏  举报