第一次结对作业

校园失物招领:从原型到可运行的 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

说句实话:这个 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 的唯一印象是"不用写类",
所以这次从两处入手学:

  1. 看官方文档的 Getting Started,主要是搞懂 assert 直接写、fixture 用装饰器声明这两件事;
  2. 看 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月10日

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

图_提交记录_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,分开放。

九、评价队友

值得学习的地方:

交接这一块做得很好,一次性让我给清楚文档进行交接工作
然后优化的工作做的也很好

需要改进的地方:

暂无

posted @ 2026-10-10 23:50  yesx  阅读(9)  评论(0)    收藏  举报