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%,主要超时集中在三个环节:
- 需求分析(超60分钟):初学FastAPI和Pydantic,花了较多时间理解请求模型校验、依赖注入等概念,比预期学习曲线陡。
- 具体编码(超220分钟):发布页的图片框选裁切交互比想象中复杂——指针捕获、多指守卫、4: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 关键实现的流程图
核心业务数据流图
发布信息流程图
状态更新流程图
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 实现思路
图片裁切实现思路:
- 用户选图后,用
URL.createObjectURL加载到<img>元素 - 给一个默认的最大居中4:3框(与详情页展示比例一致)
- 用Pointer Events(一套代码同时覆盖鼠标和触摸)实现三种手势:拖角缩放(对角固定,比例锁定4:3)、框内移动、框外重新框选
- 手势优先级:角柄 > 框内 > 框外(角柄热区压在框边上,必须最先判定)
- 框选结果存为源图像素坐标(不是屏幕坐标),窗口缩放时只需重新摆放显示位置
- 导出时
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 实现成果展示
启动项目后,主要页面效果如下:
首页:深绿色"拾"字水墨风格封面,下方是最新信息列表,支持全部/寻物/招领三个分类切换,底部有发布按钮。每张卡片左侧有金色边线,包含图标、名称、类型标签、状态徽标、时间、地点和两行描述。

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


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


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


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

六、目录说明和使用说明
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
启动后会自动:
- 创建SQLite数据库并写入8条演示数据
- 启动FastAPI服务(默认 127.0.0.1:8000)
- 用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调用栈,包括依赖注入、异常处理、中间件
- 通过覆盖依赖把数据库指到临时文件,测试之间互不干扰
学习路径:
- 先看了廖雪峰的Python教程中关于单元测试的部分,理解assert和fixture的基本概念
- 阅读了Mocha实例教程了解JavaScript测试框架的思想(虽然项目后端用Python)
- 参考了邹欣老师的博客关于单元测试和回归测试,理解"测试要自动化""每日构建运行单元测试"的原则
简易教程:
# 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个测试断言。覆盖了正常路径、边界值、异常输入、幂等性、字段契约、路径穿越防护等各方面。

八、GitHub代码签入记录

* 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一次。
九、遇到的代码模块异常或结对困难及解决方法
困难一:发布页图片框选——指针在图片外松手后拖动状态清不掉
问题描述:在发布页测试图片框选时发现,按住鼠标在图片上拖出一个框,然后继续按住拖到图片外面松手,之后不用按鼠标键,只要把鼠标移回图片区域内,选择框就会跟着鼠标继续移动——好像鼠标一直按着一样。
做过哪些尝试:
- 一开始以为是
pointerup事件没绑定好,检查了好几遍onpointerup属性确认没问题 - 在
endSelect里加console.log发现图片外松手时这个函数根本没被调用 - 查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,详情页时间标签显示空白。
做过哪些尝试:
- 最初考虑在前端做一层转换函数,但觉得多一层映射容易漏字段
- 后来决定在序列化层(
serialize.py的row_to_detail_item)直接按前端需要的名字输出,这样后端测试和前端看到的字段名一致 - 但担心以后改字段名时只改了一边,于是写了契约测试:把前端
renderCard/render/renderSummary实际读取的字段名写死在测试里,接口返回的字段集合必须与之完全一致
是否解决:是。序列化层按前端命名输出字段,契约测试锁住一致性。后来真的改过一次(加image字段),契约测试立刻失败提醒同步更新。
有何收获:前后端分离项目里,字段命名是接口契约的一部分,不能靠"两边各改一次"来保证一致——必须有测试锁住。另外,响应模型的字段名应该以前端实际读取的名字为准,不做前后端命名风格转换,少一层映射就少一处出错的可能。
困难三:SQLite路径在打包成exe后丢失数据
问题描述:项目用PyInstaller打包成单文件exe后,发现每次双击启动都像是第一次运行——之前发布的数据、标记过的已解决状态全都不留痕。
做过哪些尝试:
- 一开始用
__file__往上推算数据库路径,但在冻结环境里__file__指向的是进程启动时解压出来的临时目录(sys._MEIPASS),进程一退就被删 - 尝试用
sys.executable(exe自身的路径)的同级目录,这样数据库和上传图片都落在exe旁边,用户能直接看到、备份、删除重来 - 写了
test_paths.py用monkeypatch模拟sys.frozen/sys.executable/sys._MEIPASS,把各个路径分支单独测出来——这正是最容易被改坏又在开发时看不出来的地方
是否解决:是。数据库和上传目录在开发时落在仓库根目录,打包后落在exe同级目录。两条路径都由default_db_path()和default_uploads_dir()统一管理,且有测试保证"冻结时不会把数据库放进临时解压目录"。
有何收获:打包环境本身没法在测试里跑(要真的构建一次exe),但可以用monkeypatch模拟冻结状态来测路径解析逻辑。这种"开发时永远对、一打包就出问题"的bug,如果没有测试盯着,只能等用户报"数据丢了"才发现——那时已经晚了。
十、评价你的队友
值得学习的地方
队友在前端交互上的打磨非常到位。发布页的图片框选裁切功能,从一开始的"选一张图直接传"演进到"在图上拖框选、拖角缩放、框内移动",每个手势的交互细节都仔细考虑过——指针捕获防框外松手、多指守卫防两根手指互相抢、源图坐标存储防窗口缩放后框飘了。这些细节不做也不会有功能问题,但做了体验就完全不一样。
另外,朱铭浩在代码注释上写得很用心。每个函数开头都有说明"做什么""参数是什么""为什么这样写",尤其是标注了几个"这里踩过坑"的地方(如指针捕获),后来写测试时直接对着注释里提到的点写断言。这种"注释说明WHY而非WHAT"的习惯值得学习。
需要改进的地方
队友在前端样式上花的时间偏多,有时候为了调整一个卡片的圆角和阴影来回改了好几版,挤占了后端接口联调的时间。另外,开发前期的需求分析阶段,两人对"是否要做图片上传"这个问题讨论得不够充分——如果先确定要做,后端上传接口可以和前端裁切逻辑同步开发,不必等前端裁切做完了才发现后端接口还没写。
总的来说,结对编程的效果不错。我们两个人的分工明确——我管后端接口和数据,队友管前端页面和交互——但通过GitHub的Pull Request互相review代码,确保两边都了解对方在做什么。接口字段对接时发现的不一致也通过契约测试固化下来,不会再出现"改了一边忘了另一边"的情况。
浙公网安备 33010602011771号