2026秋软件工程结对作业(第二次之程序实现)

2026秋软件工程第二次结对作业 —— 校园失物招领

这个作业属于哪个课程 H202601软件工程与软件工程实践
这个作业要求在哪里 2026秋软件工程结对作业(第二次之程序实现)
这个作业的目标 将校园失物招领原型实现为可运行的 Web 应用,完成“发布信息—浏览或搜索—查看详情—联系发布者—更新状态”核心业务流程,并通过 GitHub 协作、PSP 和单元测试实践结对开发、版本管理与软件测试
GitHub 仓库 102402144-102402151
结对成员 102402144 张家祥、102402151 朱铭浩
张家祥博客 slangtingbell
朱铭浩博客 Emmamone

二、具体分工

成员 主要负责模块 具体内容
张家祥(102402144) 后端开发 数据库设计(db.py)、接口路由(main.py)、请求/响应模型(schemas.py)、数据序列化(serialize.py)、图片上传校验(uploads.py)、演示数据播种(seed.py)、启动入口(main.py)
朱铭浩(102402151) 前端开发 5个页面HTML编写(index/search/detail/publish/success)、每页对应JS脚本、主题样式(visual-theme.css)、API封装层(api.js)、公共工具函数(common.js)、发布页图片裁切交互(publish.js)
共同完成 测试与文档 单元测试用例设计、集成测试与契约测试编写、README文档、PSP表格、博客撰写

两人通过GitHub协作,张家祥创建仓库,朱铭浩 fork 后通过 Pull Request 提交前端代码。开发过程中通过微信实时沟通接口字段与前端渲染需求的对接细节。


三、PSP表格

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划
Estimate 估计这个任务需要多少时间 30 25
Development 开发
Analysis 需求分析(包括学习新技术) 120 180
Design Spec 生成设计文档 90 110
Design Review 设计复审 40 50
Coding Standard 代码规范(为目前的开发制定合适的规范) 30 35
Design 具体设计 150 200
Coding 具体编码 600 820
Code Review 代码复审 90 120
Test 测试(自我测试,修改代码,提交修改) 180 260
Reporting 报告
Test Report 测试报告 60 75
Size Measurement 计算工作量 20 25
Postmortem & Process Improvement Plan 事后总结,并提出过程改进计划 40 50
合计 1450 1950

PSP复盘分析

实际耗时比预估多出约35%,主要超时集中在三个环节:

  1. 需求分析(超60分钟):初学FastAPI和Pydantic,花了较多时间理解请求模型校验、依赖注入等概念,比预期学习曲线陡。
  2. 具体编码(超220分钟):发布页的图片框选裁切交互比想象中复杂——指针捕获、多指守卫、4:3比例锁定、源图坐标与屏幕坐标换算等细节反复调试。后端图片上传的魔数校验也花了额外时间验证各种伪造场景。
  3. 测试(超80分钟):最初只写了单元测试,后来发现接口字段与前端渲染字段容易脱节,补写了一批契约测试。修改代码后部分测试失败、来回修bug也占了时间。

编码阶段低估了前端交互的复杂度,下次类似任务应在前端交互原型阶段就拆分更细。


四、解题思路描述与设计实现说明

4.1 代码实现思路

本项目采用前后端分离架构,前端5个独立HTML页面只负责发请求和渲染,后端用Python FastAPI + SQLite提供6个RESTful接口,筛选、搜索、排序、状态变更、脱敏、编号生成全部在服务端完成。

核心流程:发布信息 → 浏览或搜索 → 查看详情 → 联系发布者 → 更新状态

具体实现思路如下:

(1)数据层设计

数据库只有一张items表,用SQLite存储。每条记录包含物品名称、类型(seek寻物/find招领)、状态(seeking/unclaimed/resolved)、类别、描述、时间、地点、联系方式、脱敏串、搜索索引等字段。不使用ORM,直接用stdlib的sqlite3操作,每请求一个连接,开启WAL模式以支持并发读写。

关键设计:演示数据(8条)和用户新发布的数据在同一张表里,通过source字段(demo/user)区分排序权重——用户新发布的排在最前,演示数据按预设的home_order和search_order固定顺序排列。

(2)接口层设计

6个接口覆盖全部核心功能:

接口 方法 功能
/api/items/home GET 首页最新信息列表,支持type参数筛选
/api/items/search GET 关键词搜索,对搜索索引文本做不区分大小写子串匹配
/api/items/{id} GET 获取单条物品完整详情
/api/items POST 发布新信息,服务端补全状态、图标、编号、脱敏串等字段
/api/items/{id}/resolve POST 标记为已解决,幂等设计
/api/uploads POST 上传图片,返回服务端生成的随机文件名

(3)前端层设计

5个页面各自独立,不做单页应用(SPA)。每页一个JS文件,加上共用的api.js(接口封装)和common.js(工具函数)。页面间通过URL参数传递数据(如detail.html?id=1),不依赖localStorage——所有状态由服务端保存,所有人看到的是同一份数据。

主题样式只有一份visual-theme.css,通过覆盖Tailwind类名实现统一配色。卡片模板renderCard被首页和搜索页共用,确保两页渲染一致。

(4)安全设计

  • 联系方式脱敏:手机号前三后四(138****1234),其他长文本前二后二(微信****24),短于4位原样返回
  • 图片上传三重校验:MIME白名单 + 文件大小上限 + 文件头魔数校验(防止把文本文件标成image/png绕过)
  • 文件名由服务端生成(uuid4().hex + 白名单扩展名),外部输入不参与路径拼接,路径穿越在结构上不可能发生
  • HTML注入防护:所有来自接口或用户输入的内容写入innerHTML前都经过escapeHtml转义

4.2 关键实现的流程图

核心业务数据流图

graph TD subgraph 前端页面 A["发布页 publish.js"] B["成功页 success.js"] C["首页 index.js"] D["详情页 detail.js"] E["搜索页 search.js"] end subgraph 后端 FastAPI F["路由层 main.py"] G["序列化层 serialize.py"] H["数据库层 db.py / SQLite items表"] end F -->|调用| G -->|读写| H A -->|"POST /api/uploads"| F A -->|"POST /api/items"| F F -->|"返回新id + DetailItem"| A A -->|"跳转 success.html?id=N"| B B -->|"GET /api/items/id"| F F -->|"返回 DetailItem"| B C -->|"GET /api/items/home"| F F -->|"返回 count + items"| C C -->|点击卡片| D D -->|"GET /api/items/id"| F F -->|"返回 DetailItem 含脱敏联系方式"| D D -->|"POST /api/items/id/resolve"| F F -->|"返回 status=resolved"| D E -->|"GET /api/items/search"| F F -->|"返回 count + items"| E

发布信息流程图

graph TD A["用户打开发布页 publish.html"] --> B["选择发布类型 seek / find"] B --> C["填写物品信息 6项必填字段"] C --> D{"是否选择了图片?"} D -->|是| E["在图上拖框选 / 4:3比例锁定 / 拖角缩放 / 框内移动 / 框外重选"] E --> F["Canvas导出JPEG 压缩到1280px内"] F --> G["POST /api/uploads 拿到filename"] G --> H D -->|否| H["POST /api/items 提交7个字段"] H --> I["服务端补全字段: status / icon / code / masked / keywords / published_at / publisher"] I --> J["写入SQLite数据库 source=user"] J --> K["返回完整 DetailItem 含服务端生成的id"] K --> L["跳转 success.html?id=新id"]

状态更新流程图

graph TD A["详情页 detail.js"] -->|"点击 标记为已找回 / 已归还"| B["POST /api/items/id/resolve"] B --> C["服务端查找该id的记录"] C --> D{"记录存在?"} D -->|否| E["返回 404 not_found"] D -->|是| F{"status 已是 resolved?"} F -->|"是 跳过UPDATE"| G["直接返回原行 幂等不报错"] F -->|否| H["UPDATE status=resolved WHERE id=?"] H --> I["重新查询该行"] G --> J["返回 DetailItem status=resolved"] I --> J J --> K["前端重新渲染详情页: 状态徽标变绿 / 按钮置灰 / 文案改为已标记为已解决"]

4.3 重要代码片段与解释

片段1:搜索接口——服务端关键词匹配(backend/main.py)

@app.get("/api/items/search", response_model=schemas.HomeResponse, summary="搜索物品")
def search_items(
    q: str = Query("", description="关键词,空串表示不按关键词过滤"),
    type: str = Query("all", pattern="^(all|seek|find)$", description="筛选类型,默认 all"),
    conn: sqlite3.Connection = Depends(get_conn),
) -> dict:
    rows = conn.execute(
        """
        SELECT * FROM items
        WHERE (:type = 'all' OR type = :type)
          AND (:q = '' OR instr(lower(keywords), lower(:q)) > 0)
        ORDER BY (source = 'user') DESC, search_order ASC, id DESC
        """,
        {"type": type, "q": q},
    ).fetchall()

    now = datetime.now()
    items = [serialize.row_to_list_item(row, now) for row in rows]
    return {"count": len(items), "items": items}

这段代码实现了搜索的核心逻辑。关键词匹配用的是instr(lower(keywords), lower(:q)),对搜索索引文本做不区分大小写的子串匹配。搜索索引在发布时由build_keywords函数拼装,包含物品名称、类别、地点、描述和类型同义词(寻物加"丢了"、招领加"捡到"),所以搜"蓝色卡套""3号楼""考研加油"这些只出现在描述里的词也能命中。排序上用户新发布的(source='user')排在最前,演示数据按预设的search_order固定顺序。

片段2:联系方式脱敏(backend/serialize.py)

MOBILE_RE = re.compile(r"^1\d{10}$")

def mask_contact(value: Any) -> str:
    text = ("" if value is None else str(value)).strip()
    if not text:
        return "—"
    if MOBILE_RE.match(text):
        return text[:3] + "****" + text[7:]
    if len(text) > 4:
        return text[:2] + "****" + text[-2:]
    return text

脱敏逻辑与前端保持一致:11位手机号(1开头)前三后四中间用星号,其他长文本前二后二,短于4位的不脱敏。列表卡片上只展示脱敏串,完整联系方式只在详情页用户点击"联系发布者"后才展开。这个设计在保护隐私的同时,也让用户能判断联系方式的大致类型(手机号/微信号/QQ号)。

片段3:图片上传的魔数校验(backend/uploads.py)

_MAGIC_CHECKS = {
    ".jpg": lambda data: data.startswith(b"\xff\xd8\xff"),
    ".png": lambda data: data.startswith(b"\x89PNG\r\n\x1a\n"),
    ".webp": lambda data: data[:4] == b"RIFF" and data[8:12] == b"WEBP",
}

def looks_like_image(content_type: str, data: bytes) -> bool:
    extension = extension_for(content_type)
    check = _MAGIC_CHECKS.get(extension) if extension else None
    return bool(data) and check is not None and check(data)

只信Content-Type等于让调用方自证合法——把任意文件标成image/png就能绕过。这里按文件头(魔数)判断字节是不是它声称的那种图片:JPEG以\xff\xd8\xff开头,PNG以\x89PNG\r\n\x1a\n开头,WebP要求前4字节是RIFF且第8-12字节是WEBP。WebP特别要同时看两个标记,因为任何RIFF容器(比如WAV音频)前4字节都是RIFF。

片段4:发布页图片裁切——源图坐标存储与指针捕获(frontend/js/publish.js)

// 把指针的屏幕坐标换算成原图像素坐标
function toSourcePoint(event) {
  var box = document.getElementById('cropStage').getBoundingClientRect();
  return {
    x: (event.clientX - box.left) * (stagedImage.naturalWidth / box.width),
    y: (event.clientY - box.top) * (stagedImage.naturalHeight / box.height)
  };
}

// 按下时捕获指针,保证拖到图片外面也能收到 move 和 up
function startSelect(event) {
  if (!stagedImage) { return; }
  event.preventDefault();
  if (dragPointerId !== null && event.pointerId !== dragPointerId) { return; }
  if (event.currentTarget.setPointerCapture) {
    event.currentTarget.setPointerCapture(event.pointerId);
  }
  // ... 判定角柄/框内/框外,设置 dragMode
}

框选结果selection存的是源图像素坐标而非屏幕坐标,这样窗口尺寸变化时只需重新摆放框的显示位置(layoutSelection),框的数据本身不用动;导出时它就是drawImage要的那块矩形,不用换算。指针捕获解决了一个真实踩过的坑:不捕获时,按住鼠标拖到图片外面松手,pointerup落在别的元素上、舞台收不到,dragMode永远清不掉,之后不用按键只把鼠标移回图内就会继续拖动。

片段5:发布接口——服务端字段补全(backend/main.py)

now = datetime.now()
values = {
    "name": payload.name,
    "type": payload.type,
    "status": serialize.DEFAULT_STATUS[payload.type],   # 寻物→seeking, 招领→unclaimed
    "category": payload.category,
    "icon": serialize.DEFAULT_ICON[payload.type],       # 按类型给通用图标
    "card_desc": payload.desc,                           # 卡片用CSS截断,不另写短描述
    "desc": payload.desc,
    "happened_at": payload.time,
    "time_display": None,                               # 留NULL,读取时按今天/昨天推导
    "published_at": now.strftime("%Y-%m-%d %H:%M"),     # 服务端时间,不信客户端
    "publisher": serialize.DEFAULT_PUBLISHER,           # 没有账号体系
    "masked": serialize.mask_contact(payload.contact),  # 由contact现算脱敏串
    "contact": payload.contact,
    "keywords": serialize.build_keywords(               # 拼装搜索索引
        payload.name, payload.category, payload.place, payload.desc, payload.type
    ),
    "image": payload.image,
    "source": "user",          # 用户新发布,排序时排在最前
    "home_order": None,        # 不参与首页固定排序
    "search_order": None,
}

前端只传7个字段(name/type/category/time/place/desc/contact + 可选image),其余全部由服务端补全。发布时间用服务端当前时间而非客户端时钟(客户端时间可能不准),状态按类型自动设为seeking或unclaimed(新发布的不可能是resolved),脱敏串由联系方式现算,搜索索引拼入名称、类别、地点、描述和类型同义词。source设为'user'使新数据在列表中排在演示数据之前。


五、附加特点设计与展示

5.1 设计的创意独到之处与意义

特点一:发布页图片框选裁切(零第三方依赖)

大多数学生项目处理图片上传只做"选一张图直接传",但实际场景中用户拍的照片往往包含大量无关背景,直接上传既浪费带宽又影响详情页展示效果。我们在发布页实现了一个完整的图片框选裁切交互,用户可以在原图上拖动框选、拖四角等比例缩放、框内移动位置,框到的内容就是详情页会展示的样子。关键是这个功能完全用原生Canvas和Pointer Events实现,没有引入任何第三方裁切库,保持了项目零前端依赖的约束。

意义:让用户在发布时就控制好展示画面,详情页不会再被object-cover裁掉重要内容。同时导出时压缩到1280px以内、JPEG 0.85质量,一张图通常只有100-300KB,减轻服务端存储压力。

特点二:搜索索引包含类型同义词

发布时拼装的搜索索引里,寻物自动追加"寻物 丢了",招领自动追加"招领 捡到"。用户搜"丢了"能找到所有寻物信息,搜"捡到"能找到所有招领信息,不用记具体物品名称。这个设计很小但很实用——失主最常搜的词恰恰是"丢了"和"捡到"。

5.2 实现思路

图片裁切实现思路:

  1. 用户选图后,用URL.createObjectURL加载到<img>元素
  2. 给一个默认的最大居中4:3框(与详情页展示比例一致)
  3. 用Pointer Events(一套代码同时覆盖鼠标和触摸)实现三种手势:拖角缩放(对角固定,比例锁定4:3)、框内移动、框外重新框选
  4. 手势优先级:角柄 > 框内 > 框外(角柄热区压在框边上,必须最先判定)
  5. 框选结果存为源图像素坐标(不是屏幕坐标),窗口缩放时只需重新摆放显示位置
  6. 导出时canvas.drawImage直接用源图坐标裁切,压缩到1280px内输出JPEG

同义词搜索索引实现思路:

def build_keywords(name, category, place, desc, item_type):
    tail = "寻物 丢了" if item_type == "seek" else "招领 捡到"
    parts = [name, category, place, desc, tail]
    return " ".join(part for part in parts if part)

发布时调用此函数拼装搜索索引,存入keywords列。搜索接口对该列做instr(lower(keywords), lower(:q))子串匹配。

5.3 附加特点代码片段

图片裁切——拖角缩放(对角固定,比例锁4:3):

function resizeSelection(point) {
  var nw = stagedImage.naturalWidth;
  var nh = stagedImage.naturalHeight;
  var before = dragOrigin.selection;
  var east = dragOrigin.corner.indexOf('e') !== -1;
  var south = dragOrigin.corner.indexOf('s') !== -1;

  // 锚点取被拖角的对角——拖右下角时左上角固定
  var anchor = {
    x: east ? before.x : before.x + before.w,
    y: south ? before.y : before.y + before.h
  };

  // 宽度取两方向位移里较大的那个,再夹到"锚点到图片边缘"的最大值
  var roomX = east ? nw - anchor.x : anchor.x;
  var roomY = south ? nh - anchor.y : anchor.y;
  var limit = Math.min(roomX, roomY * SELECT_ASPECT);

  var wanted = Math.max(Math.abs(point.x - anchor.x),
                        Math.abs(point.y - anchor.y) * SELECT_ASPECT);
  var width = clamp(wanted, Math.min(MIN_SELECT_WIDTH, limit), limit);
  var height = width / SELECT_ASPECT;

  selection = {
    x: east ? anchor.x : anchor.x - width,
    y: south ? anchor.y : anchor.y - height,
    w: width, h: height
  };
  layoutSelection();
}

Canvas导出裁切结果:

function exportCroppedImage() {
  return new Promise(function (resolve) {
    if (!stagedImage || !selection) { resolve(null); return; }
    var shrink = Math.min(1, EXPORT_MAX_SIDE / Math.max(selection.w, selection.h));
    var outputW = Math.round(selection.w * shrink);
    var outputH = Math.round(selection.h * shrink);
    var canvas = document.createElement('canvas');
    canvas.width = outputW;
    canvas.height = outputH;
    // selection本身就是源图像素坐标,直接交给drawImage,无需换算
    canvas.getContext('2d').drawImage(
      stagedImage, selection.x, selection.y, selection.w, selection.h,
      0, 0, outputW, outputH
    );
    canvas.toBlob(function (blob) { resolve(blob); }, 'image/jpeg', EXPORT_QUALITY);
  });
}

5.4 实现成果展示

启动项目后,主要页面效果如下:

首页:深绿色"拾"字水墨风格封面,下方是最新信息列表,支持全部/寻物/招领三个分类切换,底部有发布按钮。每张卡片左侧有金色边线,包含图标、名称、类型标签、状态徽标、时间、地点和两行描述。

1首页00

搜索页:顶部可编辑搜索框 + 类型筛选,结果区显示关键词回显和条数,无结果时展示常见关键词快捷搜索。

2搜索页-有结果

2搜索页-无结果

详情页:完整信息展示,联系方式默认脱敏展示,点击"联系发布者"后展开完整联系方式。底部有"标记为已找回/已归还"按钮,点击后状态变为已解决、按钮置灰。

3详细页-脱敏

3详细页-展开联系方式

发布页:两步表单(选类型 → 填信息),6项必填字段,支持图片框选裁切(4:3比例锁定,拖角缩放/移动/重选),提交后跳转成功页。

4发布页-填表单

4发布页-图片框选截图

成功页:展示发布摘要 + 后续操作提示 + 查看详情/返回首页入口。

5成功页


六、目录说明和使用说明

6.1 目录结构组织

campus-lost-found/
├── frontend/                   # 前端:5个独立页面 + 共用脚本与样式
│   ├── index.html              # 首页(站点入口,兼最新信息列表)
│   ├── search.html             # 搜索结果页
│   ├── detail.html             # 信息详情页
│   ├── publish.html            # 发布信息页
│   ├── success.html            # 发布成功页
│   ├── css/
│   │   └── visual-theme.css    # 全站唯一主题样式(覆盖Tailwind类名实现配色)
│   └── js/
│       ├── api.js              # 接口封装层(全站fetch唯一出口)
│       ├── common.js           # 跨页共用工具(渲染卡片、转义HTML、提示条等)
│       ├── index.js            # 首页脚本(请求列表、渲染、分类筛选)
│       ├── search.js            # 搜索页脚本(请求搜索结果、渲染、筛选)
│       ├── detail.js           # 详情页脚本(加载详情、展开联系方式、标记已解决)
│       ├── publish.js          # 发布页脚本(表单校验、图片框选裁切、提交)
│       └── success.js          # 成功页脚本(加载摘要、设置跳转链接)
├── backend/                    # 后端:FastAPI + SQLite
│   ├── __init__.py             # 包标识,含分层约定说明
│   ├── __main__.py             # 启动入口(python -m backend,或打包成exe后双击)
│   ├── main.py                 # FastAPI应用:路由声明、依赖装配、静态挂载
│   ├── db.py                   # SQLite连接管理、建表DDL、路径解析
│   ├── schemas.py              # Pydantic请求/响应模型(校验规则)
│   ├── serialize.py            # 行→响应对象转换(纯函数,含脱敏、编号、时间文案)
│   ├── uploads.py              # 图片校验与落盘(纯函数,含魔数校验、文件名生成)
│   └── seed.py                 # 演示数据播种(8条,幂等,可重复执行)
├── tests/                      # 测试:unit / integration / contract 三层
│   ├── conftest.py             # 公共fixture(临时数据库、临时上传目录、TestClient)
│   ├── unit/                   # 单元测试(纯函数与校验规则,不碰数据库/文件系统)
│   │   ├── test_schemas.py     # 请求模型校验
│   │   ├── test_serialize.py   # 序列化纯函数
│   │   ├── test_uploads.py     # 图片校验纯函数
│   │   └── test_paths.py       # 路径解析(含打包后的exe路径)
│   ├── integration/            # 集成测试(真实HTTP请求,走临时数据库)
│   │   ├── test_items_read.py  # 三个读接口的端到端行为
│   │   ├── test_items_write.py # 发布与标记已解决
│   │   ├── test_seed.py        # 播种幂等性与启动时自动建库
│   │   ├── test_upload.py      # 图片上传与读取
│   │   └── test_static_serving.py # 静态资源托管
│   └── contract/               # 契约测试(接口字段↔前端渲染所需字段)
│       ├── test_api_contract.py    # 前后端字段契约
│       ├── test_frontend_wiring.py # 前端静态检查
│       └── test_openapi_document.py # OpenAPI文档与代码一致性
├── docs/                       # 文档
│   ├── PRD.md                  # 产品需求文档
│   ├── system-design.md        # 系统设计文档
│   ├── development-plan.md    # 开发计划
│   ├── coding-standards.md    # 代码规范
│   └── openapi.json           # OpenAPI 3.1接口文档
├── .github/workflows/ci.yml   # GitHub Actions CI配置
├── .gitignore
├── build_exe.py               # PyInstaller打包脚本
├── export_openapi.py          # 导出OpenAPI文档
├── run_server.py              # 打包入口脚本
├── requirements.txt           # 运行时依赖
├── requirements-dev.txt       # 开发依赖(含pytest/ruff/pyinstaller)
├── ruff.toml                  # 代码风格配置
├── LICENSE
└── README.md

组织原则:前端/后端/测试/文档各自独立目录,后端按职责分层(路由/数据库/模型/序列化/上传/播种),测试按层级分(单元/集成/契约),不放交叉依赖。

6.2 测试人员如何运行

前置要求:安装Python 3.10+,使用Google Chrome浏览器。

步骤一:下载项目

git clone https://github.com/slantingbell/102402144-102402151.git
cd campus-lost-found

步骤二:安装依赖

python -m venv .venv
# Windows PowerShell:
.venv\Scripts\Activate.ps1
# Git Bash:
source .venv/Scripts/activate

pip install -r requirements.txt

步骤三:启动服务(一步启动,自动建库+播种+开浏览器)

python -m backend

启动后会自动:

  1. 创建SQLite数据库并写入8条演示数据
  2. 启动FastAPI服务(默认 127.0.0.1:8000)
  3. 用Google Chrome打开首页

步骤四:访问页面

地址 内容
http://127.0.0.1:8000/ 首页
http://127.0.0.1:8000/search.html 搜索页
http://127.0.0.1:8000/detail.html?id=1 详情页
http://127.0.0.1:8000/publish.html 发布页
http://127.0.0.1:8000/docs 接口文档(OpenAPI交互式)

注意:必须通过 http:// 访问。直接双击HTML文件用 file:// 打开时,浏览器的同源策略会拦掉所有接口请求,页面会停在空列表上。

如果端口8000被占用,可以用 python -m backend --port 8001 换一个端口。


七、单元测试

7.1 测试工具与学习教程

选用的测试工具:pytest + FastAPI TestClient(基于httpx)

为什么选pytest:

  • 语法简洁,用assert直接断言,不需要self.assertEqual这类冗长写法
  • fixture机制天然适合"每个测试用临时数据库"的需求
  • parametrize可以一个测试函数跑多组数据
  • 不需要写类继承,函数即测试

为什么选TestClient:

  • FastAPI官方推荐的测试方式,不需要真的启动HTTP服务
  • 测试走真实的ASGI调用栈,包括依赖注入、异常处理、中间件
  • 通过覆盖依赖把数据库指到临时文件,测试之间互不干扰

学习路径:

  1. 先看了廖雪峰的Python教程中关于单元测试的部分,理解assert和fixture的基本概念
  2. 阅读了Mocha实例教程了解JavaScript测试框架的思想(虽然项目后端用Python)
  3. 参考了邹欣老师的博客关于单元测试和回归测试,理解"测试要自动化""每日构建运行单元测试"的原则

简易教程:

# 1. 最简单的测试:一个函数 + assert
def test_add():
    assert 1 + 1 == 2

# 2. 用fixture准备测试环境(每个测试拿到独立的临时数据库)
@pytest.fixture
def db_path(tmp_path):
    path = tmp_path / "test.sqlite3"
    seed.seed_demo_data(path)
    return path

# 3. 用TestClient测试接口(不需要真的启动HTTP服务)
@pytest.fixture
def client(db_path):
    def override():
        conn = db.connect(db_path)
        try:
            yield conn
        finally:
            conn.close()
    main.app.dependency_overrides[main.get_conn] = override
    with TestClient(main.app) as test_client:
        yield test_client
    main.app.dependency_overrides.clear()

# 4. 测试接口
def test_search(client):
    response = client.get("/api/items/search", params={"q": "校园卡"})
    assert response.status_code == 200
    assert response.json()["count"] == 2

# 5. parametrize跑多组数据
@pytest.mark.parametrize("keyword, expected", [
    ("校园卡", 2), ("钥匙", 1), ("不存在", 0)
])
def test_search_various(client, keyword, expected):
    payload = client.get("/api/items/search", params={"q": keyword}).json()
    assert payload["count"] == expected

7.2 测试代码展示与函数说明

项目测试分为三层,共约60个测试函数(含parametrize展开后约300个测试项)。展示部分代表性测试:

单元测试——联系方式脱敏(tests/unit/test_serialize.py):

@pytest.mark.parametrize(
    "raw, expected",
    [
        ("13800001234", "138****1234"),       # 11位手机号:前三后四
        ("微信:zhang_cc2024", "微信****24"),   # 长度>4:前二后二
        ("abcd", "abcd"),                     # 恰好4位:原样
        ("abc", "abc"),                       # 短于4位:原样
        ("", "—"),                            # 空:占位符
        (None, "—"),
    ],
)
def test_mask_contact(raw, expected):
    assert serialize.mask_contact(raw) == expected

测试函数mask_contact:验证联系方式脱敏的各种情况。手机号11位以1开头时前三后四中间星号,非手机号长文本前二后二,短于4位不脱敏,空值返回占位符。

单元测试——图片文件名形状校验(tests/unit/test_uploads.py):

@pytest.mark.parametrize(
    "name",
    [
        "../../main.py",           # 路径穿越
        "..\\..\\main.py",         # Windows分隔符
        "a" * 32 + ".jpg.exe",     # 双扩展名
        "A" * 32 + ".jpg",         # 大写(服务端只生成小写)
        "0123456789abcdef.jpg",    # 长度不对
        "zzzz" * 8 + ".jpg",       # 不是十六进制
        "3f2a" * 8 + ".gif",       # 扩展名不在白名单
        "",
        None,
    ],
)
def test_非法文件名被拒(name):
    assert uploads.is_valid_filename(name) is False

测试函数is_valid_filename:验证文件名形状校验能挡住路径穿越、双扩展名、大小写混写、非十六进制、非白名单扩展名等各种伪造手法。文件名必须匹配^[0-9a-f]{32}\.(?:jpg|png|webp)$(32位小写十六进制+白名单扩展名)。

集成测试——发布后立刻出现在首页第一位(tests/integration/test_items_write.py):

def test_发布后出现在首页第一位(client, create_payload):
    new_id = publish(client, create_payload).json()["id"]

    payload = client.get("/api/items/home").json()
    assert payload["count"] == 7
    assert payload["items"][0]["id"] == new_id

测试函数test_发布后出现在首页第一位:验证发布接口的返回值能被首页列表读取到,且排在第一位。这是本次改造的核心收益——旧版只写在本机localStorage,列表里根本看不到;现在写入服务端数据库后所有人都能在首页看到。

集成测试——标记已解决的幂等性(tests/integration/test_items_write.py):

def test_标记已解决是幂等的(client):
    first = client.post("/api/items/5/resolve")
    second = client.post("/api/items/5/resolve")

    assert first.status_code == second.status_code == 200
    assert first.json()["status"] == second.json()["status"] == "resolved"

测试函数test_标记已解决是幂等的:验证对同一条信息重复调用标记已解决接口不会报错,两次返回的状态都是resolved。这保证了前端按钮被点两次、或两个人同时点时结果一致。

契约测试——接口字段与前端渲染所需完全一致(tests/contract/test_api_contract.py):

CARD_REQUIRED = {
    "id": int, "name": str, "type": str, "status": str,
    "icon": str, "desc": str, "time": str, "place": str, "keywords": str,
}

def test_首页返回的字段与卡片模板所需完全一致(client):
    items = client.get("/api/items/home").json()["items"]
    for item in items:
        assert set(item) == set(CARD_REQUIRED), \
            "字段集合发生变化,前端卡片模板需要同步修改"

测试函数test_首页返回的字段与卡片模板所需完全一致:验证接口返回的字段集合与前端renderCard函数实际读取的字段完全一致。前端没有构建工具也没有Node测试环境,页面只剩渲染,所以字段改名在后端测试里可能全绿但页面会静默丢内容——这条测试锁住了这个不变量。

7.3 构造测试数据的思路

测试数据构造遵循以下原则:

1. 正常路径 + 边界值 + 异常值全覆盖

以联系方式脱敏为例,测试数据覆盖了:

  • 正常手机号(11位1开头)
  • 非手机号长文本(微信号、QQ号)
  • 恰好4位(边界)
  • 短于4位(边界)
  • 空串和None(异常)

2. 考虑测试人员的刁难

以图片上传为例,测试了这些"刁钻"场景:

  • 把文本文件标成image/png上传(魔数不符)——只信Content-Type的接口会放行
  • 文件名../../main.py(路径穿越)——外部输入参与路径拼接的接口会被攻破
  • ..\\..\\main.py(Windows分隔符)——换一种路径分隔符
  • a*32 + .jpg.exe(双扩展名)——骗过只看后缀的校验
  • A*32 + .jpg(大写十六进制)——骗过大小写不敏感的校验
  • 不带文件字段——请求形状问题返回422
  • 空文件——内容为空
  • 超过5MB——上限校验

3. 每个测试用临时数据库,不依赖其他测试

@pytest.fixture(autouse=True)
def uploads_dir(tmp_path, monkeypatch):
    target = tmp_path / "uploads"
    monkeypatch.setenv("CLF_UPLOADS_DIR", str(target))
    return target

autouse=True的fixture自动把上传目录指到临时目录,任何测试都不会往仓库里写图片。db_path fixture给每个测试一个建好表、播好8条演示数据的临时数据库。测试之间没有顺序依赖。

4. 契约测试防止前后端脱节

前端没有构建工具和Node测试环境,改了后端字段名在后端测试里可能全绿,但页面会静默丢内容。契约测试把"接口返回的字段"与"前端renderCard/render/renderSummary实际读取的字段"锁在一起——任何一方改了字段,另一方的测试就会失败。

测试用例总数:单元测试约25个函数(parametrize展开后约80项),集成测试约30个函数(约120项),契约测试约25个函数(约60项),合计约260个测试断言。覆盖了正常路径、边界值、异常输入、幂等性、字段契约、路径穿越防护等各方面。

7测试通过


八、GitHub代码签入记录

6GitHub截图

* a3f2b9c feat: 实现图片框选裁切与上传功能 (publish.js, uploads.py)
* 7d1e4f2 test: 补充契约测试——前后端字段一致性校验
* 3c8a1b6 feat: 完成发布接口与标记已解决接口 (main.py, schemas.py)
* 9f2d3e8 refactor: 前端五个页面全部接入后端接口,移除localStorage
* b4c7f1a feat: 实现搜索接口与首页列表接口 (main.py, serialize.py)
* 2a8e6d3 feat: 搭建项目骨架——后端分层、前端页面与主题样式
* 5c3b9f1 docs: 编写PRD、系统设计、开发计划与代码规范文档
* 1d4a7c2 init: 初始化仓库结构与.gitignore

Commit信息遵循Conventional Commits规范,使用feat/fix/refactor/test/docs前缀,每做完一个功能点编译成功后至少commit一次。


九、遇到的代码模块异常或结对困难及解决方法

困难一:发布页图片框选——指针在图片外松手后拖动状态清不掉

问题描述:在发布页测试图片框选时发现,按住鼠标在图片上拖出一个框,然后继续按住拖到图片外面松手,之后不用按鼠标键,只要把鼠标移回图片区域内,选择框就会跟着鼠标继续移动——好像鼠标一直按着一样。

做过哪些尝试:

  1. 一开始以为是pointerup事件没绑定好,检查了好几遍onpointerup属性确认没问题
  2. 在endSelect里加console.log发现图片外松手时这个函数根本没被调用
  3. 查MDN文档后明白:不捕获指针时,pointerup落在鼠标下面的那个元素上(图片外面的body或div),图片舞台收不到这个事件

是否解决:是。在startSelect里调用event.currentTarget.setPointerCapture(event.pointerId),捕获后即使鼠标/手指拖到图片外面,舞台仍然能收到move和up事件。另外还加了一道兜底:moveSelect里检测到event.buttons === 0(鼠标键已松开)时直接调用endSelect()结束手势。

有何收获:理解了Pointer Events的捕获机制——捕获是"把后续事件定向到指定元素"而非"捕获动作本身"。这个bug如果只在桌面端测很难发现(鼠标一般不会拖到图片外松手),但在移动端触摸时手指很容易滑出图片区域,上线后一定会被用户踩到。后来专门写了一条契约测试钉住"必须有setPointerCapture和event.buttons === 0兜底"。

困难二:前后端字段命名风格不一致导致详情页空白

问题描述:后端Python习惯用snake_case命名(如time_label、published_at),前端JavaScript习惯用camelCase(如timeLabel、publish)。后端直接返回数据库行时,前端document.getElementById('dTimeLabel').textContent = it.time_label取到undefined,详情页时间标签显示空白。

做过哪些尝试:

  1. 最初考虑在前端做一层转换函数,但觉得多一层映射容易漏字段
  2. 后来决定在序列化层(serialize.py的row_to_detail_item)直接按前端需要的名字输出,这样后端测试和前端看到的字段名一致
  3. 但担心以后改字段名时只改了一边,于是写了契约测试:把前端renderCard/render/renderSummary实际读取的字段名写死在测试里,接口返回的字段集合必须与之完全一致

是否解决:是。序列化层按前端命名输出字段,契约测试锁住一致性。后来真的改过一次(加image字段),契约测试立刻失败提醒同步更新。

有何收获:前后端分离项目里,字段命名是接口契约的一部分,不能靠"两边各改一次"来保证一致——必须有测试锁住。另外,响应模型的字段名应该以前端实际读取的名字为准,不做前后端命名风格转换,少一层映射就少一处出错的可能。

困难三:SQLite路径在打包成exe后丢失数据

问题描述:项目用PyInstaller打包成单文件exe后,发现每次双击启动都像是第一次运行——之前发布的数据、标记过的已解决状态全都不留痕。

做过哪些尝试:

  1. 一开始用__file__往上推算数据库路径,但在冻结环境里__file__指向的是进程启动时解压出来的临时目录(sys._MEIPASS),进程一退就被删
  2. 尝试用sys.executable(exe自身的路径)的同级目录,这样数据库和上传图片都落在exe旁边,用户能直接看到、备份、删除重来
  3. 写了test_paths.py用monkeypatch模拟sys.frozen/sys.executable/sys._MEIPASS,把各个路径分支单独测出来——这正是最容易被改坏又在开发时看不出来的地方

是否解决:是。数据库和上传目录在开发时落在仓库根目录,打包后落在exe同级目录。两条路径都由default_db_path()和default_uploads_dir()统一管理,且有测试保证"冻结时不会把数据库放进临时解压目录"。

有何收获:打包环境本身没法在测试里跑(要真的构建一次exe),但可以用monkeypatch模拟冻结状态来测路径解析逻辑。这种"开发时永远对、一打包就出问题"的bug,如果没有测试盯着,只能等用户报"数据丢了"才发现——那时已经晚了。


十、评价队友

值得学习的地方

队友在后端架构上的分层意识很强。项目没有用ORM,直接操作sqlite3,但他把路由、数据库、序列化、上传、播种各自拆成独立模块,每个文件职责单一,互相之间只通过函数调用衔接。后来我改前端字段名时,只需要看序列化层的输出,不用通读整个后端——这种分层让前端对接时心里有底。

另外,他在测试上花的功夫比我想象的多。我一开始觉得后端接口写完跑通就行了,但他坚持写了契约测试,把接口返回的字段和前端renderCard实际读取的字段锁在一起。后来我真的改过一个字段名(加image字段),契约测试立刻失败提醒我同步更新,省了上线后页面静默丢内容的bug。这种"用测试守住不变量"的意识是我这次最大的收获。

需要改进的地方

队友在后端接口设计上有时偏理想化。比如图片上传他一口气做了MIME白名单、文件大小上限、文件头魔数三重校验,还用正则严格限制文件名形状,功能上确实滴水不漏,但我在前端对接时花了额外时间理解这些校验规则对调用方意味着什么——如果接口文档里能多加几句"调用方需要注意什么"的说明,联调会更快。另外,需求分析阶段他对"哪些功能必须做、哪些可以省"的判断偏保守,图片裁切最初他不太想做,觉得超出作业要求范围,是我坚持才加上的——不过他也确实把它做扎实了。

总的来说,结对编程的体验不错。我管前端页面和交互,他管后端接口和数据,通过GitHub的Pull Request互相review,两边都清楚对方在做什么。接口字段对接时发现的不一致通过契约测试固化下来,不会再出现"改了一边忘了另一边"的情况。

posted @ 2026-10-09 11:39  Emmamone  阅读(14)  评论(0)    收藏  举报