第一次结对作业
校园失物招领:从原型到可运行的 Web 应用
| 项目 | 内容 |
|---|---|
| 本次作业要求 | 2026秋软件工程结对作业(第二次之程序实现) |
| 所属课程 | 2026 秋软件工程与软件工程实践 |
| 结对成员 | 102402141 叶思铖、102402142 叶腾骏 |
| 141 个人博客 | 叶思铖的博客 |
| 142 个人博客 | 叶腾骏的博客 |
| GitHub 仓库 | 102402141-102402142 |
| 实现形式 | PC Web(Flask + Jinja2 + 原生 HTML / CSS / JavaScript) |
| 测试环境 | Google Chrome、pytest(Python 单元测试框架) |
一、分工
| 成员 | 学号 | 承担的工作 |
|---|---|---|
| 叶思铖 | 102402141 | 项目搭建、数据层与路由实现、页面模板与样式、权限体系、图片素材整理 |
| 叶腾骏 | 102402142 | README 目录与使用说明、单元测试补充、按物品类别筛选功能、分支合并与验收 |
两人先一起过了第一版原型的流程和字段,之后他主要负责把功能写出来,我负责补文档、补测试、再加一个原型里没做的筛选功能,最后把分支合并到 main。
二、PSP 表格
| PSP2.1 | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|
| Estimate 估计任务时间 | 30 | 30 |
| Analysis 需求分析(含学习新技术) | 60 | 80 |
| Design Spec 生成设计文档 | 40 | 50 |
| Design Review 设计复审 | 30 | 30 |
| Coding Standard 代码规范 | 20 | 20 |
| Design 具体设计 | 60 | 70 |
| Coding 具体编码 | 300 | 380 |
| Code Review 代码复审 | 40 | 50 |
| Test 测试(自我测试、修改、提交) | 90 | 140 |
| Test Report 测试报告 | 30 | 40 |
| Size Measurement 计算工作量 | 15 | 15 |
| Postmortem 事后总结与改进计划 | 30 | 40 |
| 合计 | 735 | 945 |
说明:这里记的是两人合计的时间,因为结对的工作很难拆成"谁干了多少分钟"。
超时最多的是编码(+80 分钟)和测试(+50 分钟):编码超时主要是桌面端布局返工,
测试超时是因为后补的用例比预想的多——原以为测几条主流程就够,实际把状态机和权限的边界都补上之后,
用例数从计划的十来条涨到了二十多条。
三、解题思路与设计实现
3.1 整体架构
后端用 Flask,数据用 SQLite,页面用 Jinja2 渲染,不引入前端框架——原型是移动端卡片风格,用原生 HTML + CSS 反而更好控制。
代码分四层,每层只干一件事:

| 文件 | 职责 | 不负责什么 |
|---|---|---|
views.py |
接请求、判权限、选模板 | 不写 SQL |
db.py |
读写数据库 | 不判权限 |
validation.py |
校验表单与状态取值 | 不碰数据库 |
schema.sql |
定义表结构 | — |
这么分的好处后面立刻体现出来了:我加「按物品类别筛选」时,只需要在 db.search_items 加一个参数、在 index 路由多读一个查询参数、在模板加一行筛选按钮,没有牵动任何权限或校验逻辑。
3.2 一次请求的数据流
以「用户提交发布表单」为例:

关键点是状态不由前端传:create_item 里根据 item_type 从 STATUS_OPTIONS 取初始状态(寻物→寻找中,招领→待认领),前端只提交物品信息,没法伪造状态。
3.3 几段关键代码
① 登录校验做成装饰器
def login_required(view):
@wraps(view)
def wrapped_view(*args, **kwargs):
if "username" not in session:
next_url = request.full_path.rstrip("?")
return redirect(url_for("main.login", next=next_url))
return view(*args, **kwargs)
return wrapped_view
每个需要登录的路由挂一个 @login_required 就行,不用在每个函数里重复判断。顺手把当前地址放进 next 参数,登录完能跳回来。
② 权限必须判在后端
def update_item_status(item_id, status, owner, can_manage_all=False):
item = get_item(item_id)
if item is None or (not can_manage_all and item["owner"] != owner):
return False
if status not in STATUS_OPTIONS[item["type"]]:
return False
...
这里挡了两件事:不是自己的信息不能改,状态值必须在允许的选项里。这两个判断都放在数据层,前端藏不藏按钮都拦得住。
③ 搜索条件是拼起来的
clauses, parameters = [], []
if keyword:
clauses.append("(name LIKE ? OR location LIKE ? OR description LIKE ?)")
value = f"%{keyword}%"
parameters.extend([value, value, value])
if item_type in {"lost", "found"}:
clauses.append("type = ?")
parameters.append(item_type)
if category and category != "all":
clauses.append("category = ?")
parameters.append(category)
用的都是参数占位符,条件本身是拼字符串但值全部走参数,不存在注入问题。这也解释了为什么后来加类别筛选只花了十几分钟——在这套结构上加一个条件就是加三行。
四、附加特点
4.1 按物品类别筛选
为什么做这个:第一次作业的点评里提到"可以按物品类别、遗失或拾取地点进行筛选",
而原型里只做了「寻物/全部/招领」的类型切换,没做类别。这是个真实的缺口,所以补上了。
实现思路:
- 类别不写死在前端,而是用
list_categories()从现有数据里SELECT DISTINCT取出来——
以后新增类别不用改代码; - 切换类别时会保留当前的关键词、类型和状态,三个筛选条件可以叠加,
不会因为点了一个筛选把别的条件冲掉。
def list_categories():
"""列出当前已出现的物品类别,供首页筛选按钮使用。"""
rows = get_db().execute(
"SELECT DISTINCT category FROM items WHERE category <> '' ORDER BY category"
).fetchall()
return [row["category"] for row in rows]
效果:现在首页同时有类型和类别两行筛选,比如「生活用品 + 招领」能直接筛出相关记录,
比只按类型翻要快得多。
4.2 联系方式一键复制
详情页的联系方式旁有个复制按钮,不用手动选中文字:
<div class="contact-row">
<span>联系方式:{{ item.contact }}</span>
<button class="secondary-button" type="button" data-copy-contact="{{ item.contact }}">复制</button>
</div>
4.3 按角色区分可见范围
普通用户进「我的」只能看到自己发的,管理员能看到全部并修改任意一条的状态。
这个判断在 views.my_posts 和 db.update_item_status 两处都做,前端只是跟着显示不同内容。
4.4 界面成果
首页——顶部是搜索框,下面两行筛选:第一行是类型(全部/寻物/招领),第二行是物品类别。卡片左侧方形小图,右侧是名称、类型、地点、日期和状态;寻物用浅绿、招领用浅橙区分。

按类别筛选——点「电子产品」,只剩耳机那条,其他类别不混进来:

搜索——输入「耳机」,两条相关记录都命中(一条是种子数据,一条是我测试时真发的):

信息详情——图片、类别、地点、时间、描述,最下面是联系方式加一键复制:

登录页——不展示任何演示账号或密码,只提示「账号由项目管理员统一分配」:

发布寻物——带 * 的是必填项,联系方式自动带出当前账号的邮箱:

我的发布——顶部是头像、姓名、学号和学院;每条信息下面带状态更新下拉框,寻物给的是「寻找中/已找到」:

404 页面——随便访问一个不存在的编号,给的是友好提示而不是错误堆栈:

说句实话:这个 404 页面的提示文字和「返回首页」按钮有点靠太近,看起来挤。
属于小瑕疵,不影响功能,记录下来待改。
五、目录说明与使用说明
完整内容已写进仓库的 README.md,这里摘录要点。
5.1 目录结构
campus-lost-found/
├── app/ # 应用核心
│ ├── __init__.py # 应用工厂:创建实例、注册蓝图、注入当前用户、处理 404
│ ├── db.py # 数据访问层:连接管理、建表与种子数据、各项读写
│ ├── schema.sql # 表结构(users / items)
│ ├── validation.py # 常量与表单校验
│ └── views.py # 路由层:页面路由、登录校验、权限判断
├── static/
│ ├── css/style.css # 全局样式(手机竖屏优先,桌面居中)
│ ├── js/app.js # 前端交互
│ └── assets/ # 背景、头像、物品图等素材
├── templates/ # 11 个 Jinja2 页面模板
├── tests/
│ ├── test_app.py # 核心流程测试(16 个用例)
│ └── test_status_and_search.py # 状态、筛选、权限测试(13 个用例)
├── instance/ # SQLite 数据库存放处(不提交)
├── PROJECT_RULES.md # 协作与开发规范
├── AI_HANDOFF.md # 开发交接文档
├── requirements.txt # 依赖:Flask、pytest
└── run.py # 启动入口
5.2 怎么跑起来
py -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install -r requirements.txt
python run.py
浏览器打开 http://127.0.0.1:5000/,首次启动会自动建库并写入演示数据。
演示账号(密码统一 123456,仅本地演示用):
| 账号 | 姓名 | 角色 |
|---|---|---|
102402141 |
叶思铖 | 学生,名下有校园卡、黑色雨伞、篮球 |
102402142 |
叶腾骏 | 学生,名下有白色耳机、水杯 |
admin |
系统管理员 | 可查看和修改全部信息 |
跑测试:
.\.venv\Scripts\python.exe -m pytest -q
六、单元测试
6.1 用的什么工具,怎么学的
用 pytest。之前只写过 Python 自带的 unittest,对 pytest 的唯一印象是"不用写类",
所以这次从两处入手学:
- 看官方文档的 Getting Started,主要是搞懂
assert直接写、fixture 用装饰器声明这两件事; - 看 Flask 官方文档的 Testing 章节,重点是
app.test_client()——它让测试不用真的起服务器,
直接模拟请求就能走完一整套路由、模板和数据库流程。
给同样想上手的人一份迷你教程:
import pytest
from app import create_app
# 1. fixture:准备测试用的 app,tmp_path 是 pytest 给的临时目录
@pytest.fixture
def app(tmp_path):
return create_app({
"TESTING": True,
"DATABASE": str(tmp_path / "test.sqlite3"), # 每个测试一个独立库
})
# 2. 用 assert 直接断言,不用记 assertEqual 那一套
def test_health(app):
client = app.test_client()
response = client.get("/health")
assert response.status_code == 200
# 3. 需要登录的场景:先 POST 登录,再访问受保护页面
def test_mine_requires_login(app):
client = app.test_client()
response = client.get("/mine")
assert response.status_code == 302
assert "/login" in response.headers["Location"]
运行就一句:python -m pytest -q。
6.2 部分测试代码
挑三个说:
① 状态机初始值——这是需求里写死的规则,但一开始没测:
def test_new_found_item_starts_as_pending_claim(app):
client = app.test_client()
login(client)
response = client.post("/publish/found", data={...}, follow_redirects=True)
body = response.get_data(as_text=True)
assert response.status_code == 200
assert "待认领" in body # 招领的初始状态
assert "寻找中" not in body # 不能串到寻物那一组
② 越权修改——普通用户不能改别人的信息:
def test_normal_user_cannot_update_another_users_item(app):
client = app.test_client()
login(client) # 登录 102402141
response = client.post("/items/4/status", data={"status": "已找到"})
assert response.status_code == 400 # 4 号是 102402142 的
③ 搜索无结果的空状态——不能只测顺利的路径:
def test_search_without_match_shows_empty_state(app):
client = app.test_client()
response = client.get("/?keyword=完全不存在的物品名称")
body = response.get_data(as_text=True)
assert response.status_code == 200
assert "没有找到相关信息" in body
6.3 测试数据是怎么构造的
思路是"用种子数据当已知答案,用临时数据当被测对象":
- 正向用种子数据:库里有固定的五条记录(校园卡、黑色雨伞、篮球、白色耳机、水杯),
每条的类型、地点、类别、归属人都是确定的,所以可以直接断言。
比如"搜『西区』应该命中西区教学楼",因为种子数据里正好有两条在西区。 - 被测数据临时造:需要发布新信息时,就在测试里现传表单,
不依赖种子数据的具体条数,这样以后加种子数据也不会让测试失效。 - 每个测试一个独立数据库:用
tmp_path而不是共用一个文件。
这一点是踩过坑之后改的——最早几个测试连着跑,前面改过的状态会影响后面的断言。
6.4 怎么考虑"测试人员的刁难"
几条边界是专门为这个设计的:
| 刁难方式 | 对应测试 |
|---|---|
| 表单整片留空 | test_publish_requires_required_fields 断言返回 400 |
| 手写一个不存在的状态值 | test_invalid_status_value_is_rejected |
| 不登录直接访问受保护页面 | /mine、/publish、/publish/lost 三个入口都测了跳转 |
| 改别人的信息 | 普通用户改 4 号记录应返回 400 |
| 搜一个不存在的关键词 | 断言空状态提示,且不出现任何种子数据 |
| 访问不存在的详情页 | test_missing_item_has_friendly_not_found_page |
| 登录页有没有偷偷渲染出账号密码 | test_login_page_does_not_expose_demo_credentials_or_register_link |
最后一条是特意加的:开发时为了方便,曾经把演示账号写在登录页上,后来删掉了,
但光删掉不够,得有个测试锁着,不然以后谁顺手加回来都没人发现。
最终一共 29 个用例,pytest -q 全部通过。
七、代码签入记录
10 月 10 日(叶腾骏):

10 月 7 日 — 9 日(叶思铖):

提交信息统一用 feat / fix / docs / test / style / chore 前缀,
每个提交只做一件事。分支流程是:功能分支 → Pull Request → 合并到 main,
本次一共开了 5 个 PR。
顺带说一句:这里能看出两人的节奏不太一样——他集中在 10 月 7 到 9 日把功能从零写完,
我是在功能齐了之后补文档、补测试、再加类别筛选。结对的顺序和单独写代码确实不同,
前面有个人把骨架撑起来,后面接手会轻松很多。
八、遇到的困难与解决
困难一:权限判断放错了地方
问题:一开始的想法是"不是管理员就不显示『更新状态』按钮",前端不做就完了。
尝试:写完发现不对——按钮藏起来了,但接口还在。手动发一个 POST 请求照样能改别人的信息。
解决:把判断挪到后端,update_item_status 里比对 owner,管理员才走 can_manage_all 分支。
收获:前端控制只是界面,后端控制才是权限。这件事想明白之后,我在补测试时专门加了一条"普通用户改别人信息应返回 400",把这条规则锁死。
困难二:登录页把演示账号直接写在上面
问题:开发阶段为了方便,登录页上直接印了"演示账号:102402141 / 123456"。
尝试:一开始觉得反正是课程演示,无所谓。后来意识到两个问题:一是不该这么做,
二是就算这次删掉,以后也可能被顺手加回来。
解决:把提示删掉,同时补了一条测试:
def test_login_page_does_not_expose_demo_credentials_or_register_link(app):
response = app.test_client().get("/login")
body = response.get_data(as_text=True)
assert "123456" not in body
assert "立即注册" not in body
收获:修 bug 之后最好补一条测试,不然这个坑很容易再踩一次。
困难三:测试之间互相干扰
问题:早期几个测试共用一个数据库文件,单独跑都过,一起跑就有几条挂掉。
尝试:看了半天才发现是前一个测试改过的状态留在了库里。改成 tmp_path 给每个测试建独立数据库后就好了。
收获:测试要隔离。一个测试的结果不能依赖另一个测试跑没跑过。
困难四:合并分支时两个 PR 撞在同一个文件上
问题:我提的两个 PR 都动了 tests/test_status_and_search.py,GitHub 判定冲突,合不了。
尝试:先在本地同步了文件内容,让后来的那个 PR 只剩功能改动,冲突就消掉了。
收获:提交粒度要小,但别让多个 PR 改同一个文件。后来我改成功能一个 PR、测试一个 PR,分开放。
九、评价队友
值得学习的地方:
交接这一块做得很好,一次性让我给清楚文档进行交接工作
然后优化的工作做的也很好
需要改进的地方:
暂无

浙公网安备 33010602011771号