软工第四次作业
2026秋软件工程第二次结对作业 —— 校园失物招领 APP 的实现记录
〇、基本信息
| 项目 | 内容 |
|---|---|
| 作业链接 | 2026秋软件工程第二次结对作业之程序实现 |
| 结对同学博客链接 | 周山翔(博客园:zsx123456) |
| 我的博客链接 | 张诗培(博客园:zsp-) |
| 结对仓库地址(GitHub) | https://github.com/zsp-c/102402145-102402149 |
| 学号 / 姓名 | 102402145 张诗培、102402149 周山翔 |
| 呈现形式 | APP(Flutter,Android) |
| 群内结对表 | 已在 deadline1 前填写,并已填写 GitHub 项目地址 |
一、分工与协作方式
我们这次没有按「谁写前端谁写后端」来分,而是先立契约、再按模块认领:把接口文档(apifox_openapi.json)里的路径、方法、参数、返回结构先全部敲定,之后两个人各挑互不重叠的模块开工。这个顺序很重要——因为真正拖慢进度的一般不是写代码,而是「我以为你那样写、你以为我这样写」。
我这边主要承担后端骨架和一半业务(鉴权、用户、物品),队友承担另两条业务线(消息、搜索)以及前端里对应的两个页面。
| 模块 | 主要负责人 | 具体内容 |
|---|---|---|
| 后端工程骨架 | 我 | Maven 三模块拆分(campus-common / campus-pojo / campus-server)、统一响应体 ApiResponse、业务异常体系与全局异常处理器、JacksonObjectMapper 时间序列化 |
| 鉴权与用户 | 我 | JWT 签发与校验、拦截器 + ThreadLocal 上下文、注册 / 登录 / GET,PUT /users/me / GET /users/{userId}、学号与昵称唯一性 |
| 物品模块 | 我 | 发布、首页列表、详情(浏览量自增)、修改、删除、/items/search、/items/my、/items/{id}/resolve 状态流转、/categories、图片 OSS 上传与签名 URL |
| 消息模块(认领联系 + SSE) | 队友(周山翔) | claim 表读写、消息列表与未读数、单条 / 全部已读、ClaimSseManager 实时推送、GET /claims/stream |
| 搜索模块(历史 + 分页) | 队友(周山翔) | search_history 建表脚本、搜索历史「同词置顶 + 上限 20」、结果滚动分页与服务端页签筛选 |
| 前端页面 | 各自认领 | 我:登录 / 注册 / 首页 / 发布 / 详情 / 我的发布 / 个人资料;队友:消息页、搜索页,以及把前端残留的假数据接回真实接口 |
| 联调收尾与缺陷修复 | 我 | 补消息的删除接口与入口、已解决物品的联系消息不再展示、已解决物品不再进首页与搜索;修一批 Flutter 时序崩溃;补顶层 README |
分支与 PR 记录
全程走 Pull Request:每人一条功能分支,互不改对方的文件,改完开 PR、写明改动范围,合并由仓库所有者执行。
| PR | 分支 | 内容 | 合并 |
|---|---|---|---|
| #1 | develop → master |
前端鉴权模块相关页面,完善后端配置 | 已合并 |
| #2 | develop → master |
完成鉴权、用户、物品模块内容 | 已合并 |
| #3 | feature/claim-module → develop |
消息模块:认领联系接口、未读统计与 SSE 实时推送 | 已合并 |
| #4 | feature/search-module → develop |
搜索模块:搜索历史接口与搜索结果分页 | 已合并 |
| #5 | feature/claim-module → master |
消息模块(含前端接线)并入稳定分支 | 已合并 |
| #6 / #7 | develop → master |
整体合并至稳定分支 | 已合并 |
二、PSP 表格
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 40 | 35 |
| · Estimate | 估计这个任务需要多少时间 | 40 | 35 |
| Development | 开发 | 1200 | 1560 |
| · Analysis | 需求分析(包括学习新技术) | 150 | 260 |
| · Design Spec | 生成设计文档 | 90 | 80 |
| · Design Review | 设计复审 | 40 | 30 |
| · Coding Standard | 代码规范(为目前的开发制定合适的规范) | 30 | 35 |
| · Design | 具体设计 | 150 | 130 |
| · Coding | 具体编码 | 620 | 740 |
| · Code Review | 代码复审 | 60 | 75 |
| · Test | 测试(自我测试,修改代码,提交修改) | 60 | 210 |
| Reporting | 报告 | 210 | 230 |
| · Test Report | 测试报告 | 60 | 70 |
| · Size Measurement | 计算工作量 | 30 | 20 |
| · Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 120 | 140 |
| 合计 | 1450 | 1825 |
时间偏差怎么看
| 环节 | 偏差 | 我的复盘 |
|---|---|---|
| 需求分析 / 学新技术 | 涨了近一倍 | 后端这套东西我是第二次用,但阿里云 OSS 的签名 URL 是全新的:一开始没想清楚「数据库该存原始地址还是带签名的地址」,返工了一次才理顺,这一块单独吃掉不少时间。 |
| 具体设计 | 反而省了 | 因为接口契约先定死了,类怎么拆、DTO 叫什么基本是照着文档落,设计阶段比预想快。 |
| 测试 | 涨了两倍多 | 联调后期几乎全耗在前端那些偶发崩溃上(见困难二)。这些不是业务 bug,但排查它们的时间比写业务还长。 |
| 设计与复审 | 略省 | 模块边界清楚,复审主要是对一遍字段名,很快。 |
一句话:我的偏差几乎全在「没做过的事」和「临门一脚的联调」;凡是契约先定下来的部分,都比我预估的顺。
三、解题思路与设计实现说明
3.1 整体思路
题目要的是一条闭环:发布信息 → 浏览/搜索 → 查看详情 → 联系发布者 → 更新状态。我在动手前先把它拆成两个问题:
- 数据放哪:全部围着
item(一条失物 / 招领信息)这张主表转——图片拆item_image子表(一物多图),联系方式放在user表(发认领时直接给对方看),搜索历史单独一张search_history,联系行为落成claim。 - 状态怎么走:信息有生命周期(寻找中 → 已找回 / 待认领 → 已归还),联系有生命周期(未读 → 已读),搜索有生命周期(关键词 → 历史)。每一个「流」都在数据库里留痕,不靠前端的临时变量——这是前后端分离项目里最容易被忽略、也最容易出问题的地方。
而且这三条流是联动的:一条物品一旦被标成已找回 / 已归还,它就从首页和搜索结果里消失,连带它产生的认领消息也不再出现在消息列表里。状态只改一处,噪音在三个页面同时收敛(见 4.2)。
技术选型(括号里是我当时的实际理由):
| 层 | 选型 | 为什么 |
|---|---|---|
| 客户端 | Flutter 3.x(Android) | 一套代码出 APP,不用同时维护两套界面 |
| 后端 | Spring Boot 3.2.5 + Java 21 | 生态最熟,出问题好查;JDK21 的虚拟线程天生适合 SSE 这类挂着不走的连接 |
| 持久层 | MyBatis-Plus 3.5.5 | 分页插件开箱即用,LambdaQueryWrapper 用方法引用写字段,不怕手滑打错列名 |
| 数据库 | MySQL 8 | 课程环境标配 |
| 图片存储 | 阿里云 OSS | 不占应用服务器带宽,后端只负责签发地址 |
| 鉴权 | JWT + 拦截器 + ThreadLocal |
服务端无状态,前端每次带 token 就行,天然贴合前后端分离 |
3.2 系统架构
架构上我想强调的一点是:「当前用户是谁」这件事只在拦截器里做一次。拦截器解析 JWT 后把 userId 塞进 ThreadLocal,下游的 Service 直接 BaseContext.getCurrentId() 就能拿到,不需要每个接口都在参数里传一遍用户 id——更不用信任前端传过来的 id(这一条在 3.6 片段三、片段四里都有体现)。
3.3 核心业务流程图
画这张图的目的是把老师上次点到的一个问题回答清楚:「联系之后这条信息怎么结束」。答案是——发起联系的人只负责发起;状态的终结点永远在发布者手里,而且只有一个入口(PUT /items/{id}/resolve)。理由很实际:只有物主知道东西到底还没还,如果允许任何人改状态,信息就成了谁都能动的公共留言板。
真机上这一环长这样(认领者填附言、由服务端补齐双方身份):

图注:认领框里只有「附言」一个输入项,昵称 / 学院 / 学号 / 手机号都不需要也不允许手填——由服务端从 token 与物品发布者推导。
3.4 消息模块的数据流(SSE 实时推送)
一个设计取舍:消息先落库、再推送,而且推送失败不回头回滚。因为推送只是「更快地通知」,数据本身已经在库里了;如果为了实时性去牺牲一致性,反而会出现「消息没了但对方其实收过提示」这种更怪的场面。推不出去就只摘掉这条坏连接,业务照常成功。
发布者那一端真实收到的样子:

图注:认领人资料 + 附言 + 来源物品一起呈现,左侧竖条是未读标记;可以直接照着上面的手机号联系。这条记录是 POST /claims 落库后经 SSE 推过去的。
3.5 搜索分页与页签筛选
这里有个我们中途改过方向的地方:最早的页签是把已经加载到本地的那一页结果筛一遍,切换非常快,但切完以后「共 N 条」这个数字是错的——因为它只统计了当前页。后来改成带着 type 重新去服务端查第一页,切换时多一次请求,换来数字和内容永远自洽。这也是我们被老师那句「界面画出来和用户能用起来是两回事」点醒之后,主动回头改掉的一处。
3.6 我负责的几段代码
片段一:JWT 拦截器 + ThreadLocal 上下文
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) {
return true; // 静态资源等直接放行
}
String uri = request.getRequestURI();
if ("/login".equals(uri) || uri.startsWith("/auth/") || "/error".equals(uri)) {
return true; // 白名单:登录、注册、错误页
}
String token = request.getHeader(jwtProperties.getUserTokenName());
try {
Claims claims = JwtUtil.parseJWT(jwtProperties.getUserSecretKey(), token);
Long userId = Long.valueOf(claims.get(JwtClaimsConstant.USER_ID).toString());
BaseContext.setCurrentId(userId);
return true;
} catch (Exception ex) {
// 校验不通过:直接返回 401 与 JSON 体,而不是抛异常让前端拿到一页 HTML
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":0,\"msg\":\"未登录或登录已过期\",\"data\":null}");
return false;
}
}
这段为什么值得贴:它其实是整个后端「谁在操作」的唯一来源。业务代码里所有 BaseContext.getCurrentId()(发物品时的作者、改状态时的所有者、发消息时的发送者)都追溯到这一处。只要这一层可信,后面所有接口就不需要相信前端传来的任何身份字段。
顺带说一个我们踩过的小细节:白名单在两个地方各写了一遍——拦截器里放行 /login、/auth/、/error,而注册拦截器的 webMvcConfiguration 里 excludePathPatterns("/login", "/auth/register")。这是有意的双保险:万一哪天有人在配置里改了排除路径,拦截器这层仍然会把非白名单请求拦住,不至于因为一处配置改动就门户大开。
片段二:业务异常只抛不处理,统一在出口汇合
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler
public ApiResponse exceptionHandler(BaseException ex) {
log.error("异常信息:{}", ex.getMessage());
return ApiResponse.error(ex.getMessage());
}
@ExceptionHandler
public ApiResponse exceptionHandler(SQLIntegrityConstraintViolationException ex) {
String message = ex.getMessage();
if (message.contains("Duplicate entry")) {
String[] split = message.split(" ");
String studentId = split[2];
return ApiResponse.error(studentId + MessageConstant.ALREADY_EXISTS);
}
return ApiResponse.error(MessageConstant.UNKNOWN_ERROR);
}
}
这样写的好处:Service 层遇到业务问题只需要 throw new ItemNotFoundException() 这类异常,不用在每个方法里写 if (xxx) return ApiResponse.error(...)。所有错误消息最终都从这一个出口出去,格式天然统一(code / msg / data 三件套),前端只要写一次解析就够了。
第二段还顺手兜住了数据库层的唯一键冲突:注册时学号重复,MySQL 会抛 Duplicate entry '102402145' for key ...,这里把学号从报错信息里抠出来,转成给人看的话「102402145 已存在」。总比把一串 SQL 报错原样丢给用户强。(应用层其实已经先查过一遍学号了,这段是最后一道保险。)
片段三:谁能改这条信息?——所有权校验
/** 校验当前登录用户是否为物品发布者。 */
private void checkOwnership(Item item) {
Long currentId = BaseContext.getCurrentId();
if (!item.getUserId().equals(currentId)) {
throw new ItemAccessDeniedException();
}
}
@Override
@Transactional
public void resolve(Long itemId) {
Item item = getExistItem(itemId);
checkOwnership(item); // 不是发布者,直接拒绝
// 寻物 → 已找回;招领 → 已归还。状态由「类型」推导,不接受前端传状态值
String resolvedStatus = TYPE_LOST.equals(item.getType()) ? STATUS_FOUND : STATUS_CLAIMED;
Item update = new Item();
update.setItemId(itemId);
update.setStatus(resolvedStatus);
update.setUpdateTime(LocalDateTime.now());
itemMapper.updateById(update);
// ... 同步累加发布者的「已完成」计数
}
解释两件事:
- 越权拦截。修改、删除、标记已解决三个接口都会先过
checkOwnership。因为「是不是我的东西」这件事服务端完全知道,就不该交给前端决定。 - 状态不由前端指定,而是从类型推导。前端只需要说「这条解决了」,至于它该变成
FOUND还是CLAIMED,由后端看着item.type决定。让前端少传一个可能传错的字段,就少一种出错方式。
片段四:图片存原始地址、返回时才签名
/** 图片文件上传,返回原始URL(不带签名)。 */
public String upload(byte[] bytes, String objectName) {
OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
try {
ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(bytes));
} finally {
if (ossClient != null) ossClient.shutdown();
}
// 返回原始URL(不带签名),存入数据库
return "https://" + bucketName + "." + endpoint + "/" + objectName;
}
/** 将 ItemVo 中的图片URL和发布者头像转为签名URL。 */
private void signVo(ItemVo vo) {
if (StringUtils.hasText(vo.getImage())) {
vo.setImage(aliOssUtil.signUrl(vo.getImage()));
}
if (!CollectionUtils.isEmpty(vo.getImages())) {
vo.setImages(vo.getImages().stream().map(aliOssUtil::signUrl).collect(Collectors.toList()));
}
// ... 发布者头像同理
}
这里是我返工过的地方,值得说清楚:一开始我把「签名后的地址」直接存进了数据库,结果发现签名会过期,过一段时间所有历史图片全变成 403。正确做法是——数据库只存原始 URL(永久有效),每次返回给前端之前再实时签名。上面分的这两个函数就是这条规则的落地:upload 只负责存,signVo 只负责在出口处把地址转成带签名的临时链接。
四、附加特点设计与展示
4.1 特点一:类型 / 类别 / 状态三层筛选
老师在上次点评里专门举过「按物品类别、遗失或拾取地点筛选」的例子。我们把它落进了后端:GET /items 与 GET /items/search 都同时接受 type(寻物 / 招领)与 category(校园卡 / 电子设备 / 证件 / 钥匙…共 8 类)两个筛选位,GET /items/my 再多一个 status(寻找中 / 已找回)。分类不是写死在界面上的常量,而是由 GET /categories 下发、后端用 CategoryConstant 统一校验——前端界面能选的,一定是后端认可的。
4.2 特点二:状态改一处,三个页面同时收敛
这是我觉得这次最「值钱」的一个设计。物品被标成已找回 / 已归还之后:
- 首页列表不再展示它(
notIn(status, FOUND, CLAIMED)); - 搜索结果同样不再展示;
- 消息列表里由它引发的认领消息也不再出现(队友实现了
filterResolvedItems)。
也就是说,用户不需要自己去判断「这条是不是已经过期了」,状态变更自动把整条链路上的残留清干净。这个特点不是多画一个按钮,而是把信息生命周期贯穿到了三个模块。
4.3 特点三:图片链路——不占服务器带宽,也不泄漏地址
图片全部走阿里云 OSS:客户端直传拿到地址,后端只存原始 URL、返回时实时签名为限时链接。好处有两个:一是不用把图片存在应用服务器上,部署轻、流量省;二是图片地址是临时的,不会因为一次截图分享而被长期盗链。
4.4 特点四:消息可以一键清理
消息是会积压的。我们在消息页顶部给了「删除已读」(DELETE /claims/read,带二次确认弹窗),单条消息支持左滑删除(DELETE /claims/{claimId})。两个接口都只允许操作与当前用户相关的记录——删除也走同样一次所有权校验,而不是前端传什么就删什么。
4.5 成果展示
下面 13 张都是 Android 真机运行(Flutter 打包的 APK)实拍,按「注册登录 → 浏览搜索 → 发布 → 联系与消息 → 个人中心」的实际使用顺序排,方便对照走查。
其中第 7、8 张来自两个不同账号(认领方zsp / 102402145,发布方test / 00000),用来证明消息是跨账号真的走通了,不是本地假数据。
① 注册与登录

注册页。学号走唯一性校验(数据库唯一键 + 应用层预检双保险);昵称 / 学院 / 年级 / 手机号在这里一次收齐,因为后面「联系 TA」时展示给对方的正是这几项。

登录页。登录成功后后端用 JwtUtil.createJWT 签发 token,密码用 BCrypt 比对、不存明文。测试账号:学号 00000 / 密码 123456。
② 首页与搜索

首页。每张卡片上有两组标签——「寻物 / 招领」表示信息类型,「寻找中 / 已归还」表示当前状态,扫一眼就知道每条信息到了哪一步;右上角铃铛进入消息页,有未读时带红点。

搜索页。右上角直接给出结果条数(「共 1 条」);列表滚动到底自动加载下一页,到底显示「没有更多了」;页签(全部 / 寻物 / 招领)带 type 重新向服务端查询,不是在前端筛已加载的结果。
③ 发布信息

发布页(上)。顶部「我丢了东西 / 我捡到东西」决定这条信息的 type;下面一列分类选项来自 GET /categories,提交时后端还会用 CategoryConstant 再校验一次,非法分类直接拒绝。

发布页(下)。描述框带 200 字计数;图片经 OSS 上传,上传失败的那张不会计入保存;丢失 / 拾取时间精确到日期与时刻。
④ 联系与消息(核心链路)

详情页点「我有线索 / 联系 TA」弹出的认领框。用户只填附言——发送者取自 JWT、接收者取自物品发布者,前端一个都传不了(对应 3.6 片段三的所有权思路)。

发布者侧的消息页:认领人资料与附言一起展示,可以直接照着联系。这条是 POST /claims 落库后经 SSE 推送过来的,左侧竖条为未读标记。

消息页空态。没有消息时给出明确文案,而不是白屏、报错,也不是塞几条假数据糊住。
⑤ 我的发布与个人中心

「我的发布」:上方是累计发布 / 进行中 / 已找回的统计;每条记录带「查看详情」「标记已找回」「删除」。这里就是状态流转的唯一入口——和 3.3 的流程图对应。

个人资料页,数据来自 GET /users/me。联系方式在这里维护,别人发起认领时会一并展示给对方。

编辑资料页,保存走 PUT /users/me,头像同样走 OSS 上传与签名。

修改已发布物品。分类 chips 走 GET /categories;保存后列表由后端重新拉取,不再依赖「手动替换本地缓存里一条」的假同步。
五、目录说明与使用说明
5.1 目录结构
102402145-102402149/
├── README.md # 目录说明 + 使用说明(内容与下面 5.2 一致,同时也已提交进仓库)
├── apifox_openapi.json # 接口契约(前后端共同遵守的唯一事实来源)
├── campus_lost_found_client/ # 前端:Flutter APP
│ ├── pubspec.yaml # 依赖声明(http / image_picker 等)
│ ├── lib/
│ │ ├── main.dart # 程序入口
│ │ ├── models/item_model.dart # 物品数据模型(含状态枚举与文案映射)
│ │ ├── data/app_data.dart # 少量本地数据与全局状态
│ │ ├── screens/ # 页面层
│ │ │ ├── login_page.dart / register_page.dart
│ │ │ ├── home_page.dart # 首页列表
│ │ │ ├── publish_page.dart / edit_item_page.dart
│ │ │ ├── search_page.dart # 搜索 + 历史 + 分页
│ │ │ ├── detail_page.dart # 详情 + 联系 + 标记已解决
│ │ │ ├── my_publish_page.dart # 我的发布
│ │ │ ├── notifications_page.dart # 消息页(SSE)
│ │ │ └── user_profile_page.dart / edit_profile_page.dart
│ │ └── services/
│ │ ├── api_service.dart # 统一网络层(token 头 / ApiResponse 解析)
│ │ └── notification_api.dart # 消息接口 + SSE 客户端(自动重连)
│ └── test/widget_test.dart # 前端单元测试
└── campus_lost_found_platform/ # 后端:Maven 多模块
├── pom.xml # 父 POM,统一版本管理
├── sql/
│ ├── campus_lost_found.sql # 主建表脚本(user / item / item_image / claim)
│ └── search_history.sql # 搜索历史建表脚本
├── campus-common/ # 通用工具与常量(ApiResponse / JwtUtil / AliOssUtil / BaseContext)
├── campus-pojo/ # 实体 / DTO / VO
└── campus-server/ # 业务实现
└── src/main/java/com/zsp/campus/
├── controller/ # 接口层
├── serveice/ # 业务层(沿用仓库既有包名)
├── mapper/ # 持久层
├── sse/ # SSE 连接管理器
├── interceptor/ # JWT 拦截器
├── handler/ # 全局异常处理器
└── config/ # MyBatis-Plus / OSS / WebMvc 配置
5.2 怎么把项目跑起来
第 1 步:建立数据库
-- 在 MySQL 里按顺序执行两个脚本(先主表,再搜索历史)
source campus_lost_found_platform/sql/campus_lost_found.sql;
source campus_lost_found_platform/sql/search_history.sql;
第 2 步:补齐后端外部配置
application.yaml 里 spring.profiles.active: dev,数据库、JWT、OSS 的实际值都写成 ${campus.*} 占位符从 profile 读取;application-dev.yml 含数据库密码与 OSS 密钥,本来就不该提交进仓库(README 里有可直接复制的模板)。请在 campus-server/src/main/resources/ 下新建 application-dev.yml:
campus:
datasource:
host: 127.0.0.1
port: 3306
database: campus_lost_found
username: root
password: 你的密码
driver-class-name: com.mysql.cj.jdbc.Driver
jwt:
user-secret-key: 你的密钥
user-ttl: 7200000
user-token-name: token
alioss:
endpoint: oss-cn-xxx.aliyuncs.com
accessKeyId: 你的KeyId
accessKeySecret: 你的KeySecret
bucketName: 你的Bucket
第 3 步:启动后端
cd campus_lost_found_platform
mvn clean package -DskipTests
java -jar campus-server/target/campus-server-1.0-SNAPSHOT.jar
# 或用开发模式:mvn spring-boot:run -pl campus-server
启动成功后监听 http://localhost:8080。
第 4 步:启动前端(APP)
cd campus_lost_found_client
flutter pub get
# 打开 lib/services/api_service.dart,把 ApiConfig.baseUrl 改成后端地址
# 真机调试写电脑的局域网 IP,如 http://192.168.1.10:8080 ;模拟器可用 10.0.2.2:8080
flutter run
说明(重要):本项目是「Flutter APP + 独立后端」的结构,不能像纯网页那样双击 html 就跑起来,需要分别启动后端与客户端。想直接体验的话可以安装我们提供的 APK。测试账号:学号
00000,密码123456。
六、单元测试
6.1 用什么测、怎么学
| 端 | 工具 | 说明 |
|---|---|---|
| 后端 | JUnit 5 + Mockito(spring-boot-starter-test 自带) |
对 Service 层做纯单元测试,用 Mockito 把 Mapper 换成假对象,不连真实数据库 |
| 前端 | flutter_test(test 包 + WidgetTester) |
对页面做 Widget 测试,验证按钮、文案、跳转 |
我们自己摸索出来的学习顺序(五步):
- 先弄清「单元测试到底测什么」:测的是一个函数在给定输入下是否给出预期输出,不是测整个系统。所以数据库、网络这些依赖必须被替换掉。
- 后端最小例子:类上加
@ExtendWith(MockitoExtension.class),用@Mock造一个 Mapper,用@InjectMocks注入被测 Service,然后when(...).thenReturn(...)打桩、assertThrows(...)/assertEquals(...)断言,最后mvn test。 - 前端最小例子:
testWidgets('描述', (tester) async { await tester.pumpWidget(MyApp()); expect(find.text('首页'), findsOneWidget); })。pumpWidget是渲染、pump/pumpAndSettle是等一帧或等动画停,find.text找控件。 - 最容易踩的坑:页面里但凡有网络请求,必须先把网络层换成假实现,否则测试会真的发 HTTP 请求然后卡到超时。
- 常用断言:
findsOneWidget(恰好一个)、findsNothing(一个都没有)、findsNWidgets(n)(n 个)。
6.2 部分测试代码与说明
按作业要求「单元测试不必上传」,下面这些测试代码保存在本地,未随仓库提交。
① 后端:不是发布者,不能标记别人的信息为已解决
@Test
@DisplayName("非发布者调用 resolve,应抛出 ItemAccessDeniedException 且不落库")
void resolve_notOwner_shouldThrow() {
Item item = new Item();
item.setItemId(2001L);
item.setUserId(9999L); // 发布者是别人
item.setIsDeleted(0);
when(itemMapper.selectById(2001L)).thenReturn(item);
try (MockedStatic<BaseContext> ctx = mockStatic(BaseContext.class)) {
ctx.when(BaseContext::getCurrentId).thenReturn(1001L);
assertThrows(ItemAccessDeniedException.class, () -> itemService.resolve(2001L));
}
verify(itemMapper, never()).updateById(any()); // 关键:一个字段都不能改
}
测试的函数:ItemServiceImpl#resolve。断言两件事——抛出了预期的越权异常,以及没有发生任何写库动作。后一条更重要,它能防住「先改库再校验」这种写法混过去。
② 后端:寻物与招领解决后的状态不同
@Test
@DisplayName("寻物提交 resolve 后变 FOUND,招领变 CLAIMED")
void resolve_statusDependsOnType() {
// 准备两条物品:一条 type=lost,一条 type=found
// ...(用 ArgumentCaptor 抓 updateById 的入参,断言 status 分别是 FOUND / CLAIMED)
}
测试的函数:ItemServiceImpl#resolve 的状态推导分支。这条用例的价值在于:状态不是前端传的,而是从 type 推出来的,所以两种类型必须各测一遍,否则「招领标记后变成已找回」这种错位根本不会被发现。
③ 后端:登录密码错误不签发 token
@Test
@DisplayName("密码不正确应抛 PasswordErrorException,且不生成 JWT")
void login_wrongPassword_shouldThrow() {
User user = new User();
user.setStatus(1);
user.setPassword(PASSWORD_ENCODER.encode("123456"));
when(userService.getByStudentId("102402145")).thenReturn(user);
LoginDto dto = new LoginDto();
dto.setStudentId("102402145");
dto.setPassword("wrong");
assertThrows(PasswordErrorException.class, () -> loginService.login(dto));
}
测试的函数:LoginServiceImpl#login。这里顺便验证了密码走的是 BCrypt 比对——如果哪天有人图省事改成明文比较,这条用例测的仍然是 matches,行为一致,但第 ① 步里 PASSWORD_ENCODER.encode 的存在会提醒他。
④ 前端:消息页无数据时展示空态
testWidgets('消息列表为空时展示空态文案', (tester) async {
await tester.pumpWidget(const MaterialApp(home: NotificationsPage()));
await tester.pump();
expect(find.text('暂无消息'), findsOneWidget);
expect(find.byType(ListView), findsNothing);
});
测试的函数:NotificationsPage 的渲染分支。
⑤ 后端:分页参数被收敛到合法区间
@Test
@DisplayName("pageSize 传 0 或 1000,应分别收敛到上下限")
void pageSize_shouldBeClamped() {
// 断言最终传给 selectPage 的 Page 对象里 size 落在 [1, MAX_PAGE_SIZE]
}
测试的函数:物品列表 / 搜索的分页参数归一化逻辑。前端传什么不该直接影响 SQL,边界值一律在后端收口。
6.3 构造测试数据的思路
| 考虑的角度 | 具体构造的数据 | 想验证什么 |
|---|---|---|
| 正常路径 | 合法 itemId + 完整字段 | 主流程能走通 |
| 边界值 | 描述 200 字 / 201 字;分页 pageNum=0 / -1;pageSize=0 / 1000;关键词 64 字 / 65 字 | 上限截断与参数收敛有没有真的生效 |
| 非法输入 | itemId 不存在;分类不在 CategoryConstant 白名单里;学号重复;描述全空格 |
该报错就报错,而不能写进脏数据 |
| 权限边界 | resolve 别人的物品;删除别人的物品;把别人发来的消息标为已读 | 越权必须被拦住 |
| 状态边界 | 对已解决的物品再调一次 resolve;删除一条已解决的物品 | 要么幂等、要么明确拒绝,不能产生中间态 |
| 空集合 | 一页一条都没有;一条历史都没有 | 返回空页而不是 null / 异常 |
| 并发 | 两个人同时登录同一账号 | SSE 连接表按 userId 存列表,两边都要能收到 |
遇到将来测试人员的「刁难」我们靠什么兜:一是所有上限都定义成常量(CONTENT_MAX_LENGTH、KEYWORD_MAX_LENGTH、MAX_KEEP、MAX_PAGE_SIZE),改边界的人一眼能找到位置;二是把参数归一化抽成独立方法(normalizeLimit、trimOverflow),异常输入只影响这一处,不会散落到业务逻辑里;三是凡是前端传进来的值一律不信,能在服务端从 token 或数据库推出来的(当前用户、接收者、解决后的状态)就绝不采信请求体。这三条加起来,测试人员即使专挑边界值打,出错面也是收敛的。
七、GitHub 签入记录
提交信息统一按「类型 + 一句话说清做了什么」来写,每完成一个能编译通过的功能就至少提交一次:
| Commit | 说明 |
|---|---|
d28df29 |
初始化仓库 |
b9d1a12 |
新增前端鉴权模块相关页面,完善后端配置 |
23813ec |
完成鉴权、用户、物品模块内容 |
62e6743 |
完成消息模块:认领联系接口、未读统计与 SSE 实时推送,并新增前端消息接口客户端 |
22a9bc4 |
消息页接入真实接口:列表与未读数走后端,接入 SSE 实时推送 |
492ddd5 |
完成搜索模块:搜索历史接口与搜索结果分页 |
546f8d9 |
搜索页签改为服务端筛选:切换全部/寻物/招领时带 type 重新分页查询 |
3640ba0 |
详情页「联系 TA」改走后端认领接口,首页铃铛红点接入未读数 |
f9a1dc5 |
前端剩余假数据接真实接口:分类走 /categories,发布者资料走 /users/ |
2cf3b6d |
Merge pull request #3 from zsp-c/feature/claim-module |
d6db699 |
Merge pull request #4 from zsp-c/feature/search-module |
f78265a |
Merge pull request #5 from zsp-c/feature/claim-module |
750cae2 |
Merge pull request #6 from zsp-c/develop |
ebfc43d |
修改消息模块bug,添加readme(补顶层 README.md;新增消息删除接口;隐藏已解决物品的消息;修一批前端框架级崩溃) |
2bd9a51 |
Merge pull request #7 from zsp-c/develop |
八、遇到的异常、困难及解决方法
困难一:后端接口都写完了,前端却还在用假数据
- 问题描述:接口层其实早早就完成了,但联调时发现消息页永远空的、首页铃铛红点永远是同一个数。查下去才发现,前端有几个页面根本没有调用接口,而是往一个本地的
AppData列表里塞假数据——页面上看起来很正常,实际上一条数据都没走通。这个问题是我在群里先提出来的。 - 做过哪些尝试:先只把消息页接上(列表与未读数走
GET /claims),结果发现消息页还是空的——因为详情页的「联系 TA」根本没产生真实的claim记录;于是再把详情页改成调POST /claims,最后把首页红点也换成GET /claims/unread-count。 - 是否解决:已解决。
lib/下再没有任何AppData.notifications/AppData.unreadCount的引用。 - 有何收获:「接口写完」和「功能能用」之间隔着一整条链路。 假数据是前后端分离项目里最大的隐形债:它让所有页面都显得正常,代价是所有人都误以为已经做完了。以后我宁愿早期就逼着前端调真接口、哪怕报错,也不要先用假数据「让页面好看」。
困难二:Flutter「同一帧里改状态 + 换路由」引发的连环崩溃
- 问题描述:联调后期,App 在几个固定场景下偶发红屏——登录后进首页、发布成功、保存资料返回、退出登录、详情页发完认领。报错有两类,一类是
Duplicate GlobalKeys detected,另一类更让人摸不着头脑:'_dependents.isEmpty': is not true。 - 做过哪些尝试:
- 先从最像的原因下手——
GlobalKey冲突。首页「切回我的发布就刷新列表」是用GlobalKey<MyPublishPageState>提到State上调refresh()做的,换成ValueNotifier<int>触发器、让页面自己addListener之后,报错少了一些,但没有根治。 - 顺着
_dependents.isEmpty往上游追,才把真正的原因分成两类:TextEditingController被用在了 dispose 之后。 详情页的认领弹窗里,await showDialog()在Navigator.pop那一刻就返回了,可弹窗此时才开始播退场动画,里面的TextField还挂在树上、仍持有这个 controller。而我们原本在 await 之后立刻dispose(),于是抛A TextEditingController was used after being disposed;更麻烦的是这个异常是在 element 树更新(performRebuild)途中抛出的,会把树更新打断在半途、留下未清理的依赖,进而连锁触发后面那个断言。改成由State持有、在dispose()里统一释放。- 同一帧里既
setState又改路由。 登录、退出、保存资料、发布成功这几处原本都是「先改全局用户对象,紧接着Navigator换页」(或反过来),两件事挤在一帧里。而路由切换本身会让 element 树搬迁(deactivate / activate),这时候再通知监听者重建,就会在搬迁途中触发断言。统一改成「先写数据,等这一帧结束(addPostFrameCallback)再触发重建或换路由」。
- 还顺手改了
main.dart的根home:从isLoggedIn ? HomePage() : LoginPage()改成固定LoginPage()。原因很隐蔽——isLoggedIn是个可变静态变量,登录后根 widget 只要再 build 一次(热重载最容易触发),home就会从LoginPage翻成HomePage,而路由栈里已经有一条HomePage了,同一个Navigator里出现两条同名路由,Overlay里就有了重复的GlobalKey。
- 先从最像的原因下手——
- 是否解决:已解决。每一条都在代码里留了注释说明「为什么不能那样写」,免得以后有人改回去。
- 有何收获:Flutter 里很多「玄学崩溃」其实是时序问题,不是控件问题。 三条经验记下来了:① controller / listener / 订阅的生命周期一律挂在
State的initState–dispose上,不要写在build里或await之后;② 路由切换、setState、ValueNotifier赋值不要挤在同一帧,addPostFrameCallback是最省事的隔离手段;③ 命令式导航(pushAndRemoveUntil)和声明式根切换(home:随状态变)不能混用,二选一。
困难三:消息列表到底该显示什么——一次方向性的返工
- 问题描述:消息模块第一版是「与我相关」——我发出的 OR 我收到的,理由是
claim一行里同时带sender_id和receiver_id,两种视角本来就是同一张表,不用 union 也不用拆表。 - 做过哪些尝试:跑真机之后发现不对劲:认领消息的语义是「有人找我」。对发送方来说,他按下发送时就已经有 toast 反馈了,那条记录再混进消息列表,只会把真正需要处理的消息冲下去。于是收窄成「只查
receiver_id = 我」,同时补了一条filterResolvedItems——物品已经解决的,它引发的联系消息也不再展示。 - 是否解决:已解决。代价我也清楚:过滤在 Java 层做,所以当前页的
total只反映「过滤之后」的条数,不再等于数据库里的真实总数。消息这个量级(几十条)够用;真要严格分页,应该把这个条件下沉到 SQL 的notIn子查询。我把这条作为已知限制写在注释里,而不是假装它不存在。 - 有何收获:「技术上更通用」不等于「用起来更对」。 收发合一的写法看起来更优雅,但用户在消息页里要找的是「谁找我」,不是「我看过什么」。设计方案要回到使用场景去验证,不能停在「这样写更简洁」上。
困难四:OSS 图片地址存进数据库之后就变成 403
- 问题描述:图片上传功能,第一版把签名后的地址直接存进了数据库。结果过了一段时间,所有历史图片全部无法访问。
- 做过哪些尝试:先怀疑 bucket 权限、跨域,最后才意识到是签名过期——签名地址本来就是限时的,把它当永久地址存下来,等于给自己埋了一个定时炸弹。
- 是否解决:已解决。规则改成「库里只存原始 URL(永久有效),返回给前端之前再实时签名」:
upload只负责上传并返回原始地址,signVo在出口处统一把图片地址与用户头像转成签名链接(代码见 3.6 片段四)。 - 有何收获:凡是「有时效的东西」都不能当作持久数据。 数据该不该落库,判断标准是它会不会变——会变的,就留到输出时再算。
困难五:前后端参数名不一致,筛选功能静默失效
- 问题描述:前端调用时传的参数是
categoryId,而后端ItemController接的是category。因为搜索页当时并没有真的传这个参数,这个问题一直没暴露出来。 - 做过哪些尝试:拿仓库根目录的
apifox_openapi.json逐字段对照,确认了两边写法确实不一致。 - 是否解决:已定位,但本轮没有改。目前
api_service.dart里getItems/searchItems发的确实是categoryId,后端收的是category,所以分类筛选实际不生效(因为暂时没传,界面上看不出来)。没顺手改的原因:这是接口层与页面层交界处的参数名,单方面改动会立刻和既有契约打架,正确做法是从契约出发统一改一次,而不是各自修自己那半边。 - 有何收获:接口契约必须当成唯一事实来源。 两个人各写各的字段名,产生的是「不报错、但功能悄悄失效」的问题——这是最难查的一类 bug,因为没有任何地方会报红。
困难六:自己的仓库卫生整理得不好
- 问题描述:项目初期为了图快,把 IDE 的
.idea/目录和 Maven 的target/编译产物一起提交进了仓库,仓库里堆了近百个和源码无关的文件;顶层也一直没建.gitignore。 - 做过哪些尝试:中途意识到问题,在 README 里写明了「建议把
application-dev.yml加进.gitignore」,但文件本身还没建起来。 - 是否解决:部分解决。README 补上了(
ebfc43d,177 行,含功能模块、技术栈、目录结构、环境要求、四步启动流程、数据库表说明、鉴权说明),但.gitignore本身仍然没建。 - 有何收获:仓库的「第一眼观感」也是交付质量的一部分。 助教 clone 下来第一件事就是看目录,一堆
target/和.idea/会让人觉得这个项目没人管。这件事的正确时机是开工第一天——写一个 5 行的.gitignore,比事后写多少说明都管用。
九、评价你的队友
值得学习的地方
- 交付质量高,而且是「能自证的」。消息模块和搜索模块基本上是他一个人独立做完的:
claim表的读写、未读数统计、单条与全部已读、ClaimSseManager的 SSE 连接管理(含心跳、坏连接摘除、按 userId 多连接),以及搜索历史的建表脚本、同词置顶、上限 20、滚动分页。他做的东西不是「看起来像」,是真的跨账号跑通了——演示时用两个账号互发,发布者侧几秒内就收到了。 - 他能很干脆地承认并回头改。搜索页签原本是在前端筛已加载的结果,切起来很快但数字不对;被指出后他没有辩解,直接改成带
type重新请求服务端。这种「宁可慢一点也要数字对」的取舍,是工程性比技术性更难的部分。 - 排查问题的条理性好。前端那批时序崩溃不好查,他能从「最像的原因」(GlobalKey)一路缩小到「
await showDialog之后立刻 dispose」这种真正的原因,并且愿意把结论写进注释留给后面的人。这件事我是受益者。
需要改进的地方
- 前端接线的节奏偏晚。页面先用假数据把样子做出来没问题,但接口一就绪就该立刻接上,否则所有页面都会显得正常、问题被一路拖到联调才爆出来(困难一就是这么来的)。建议以后每完成一个接口就顺手接一个页面,别攒到最后。
- 对 Flutter 的生命周期约束还不够熟。困难二里那些崩溃,根子都在「controller 用在了 dispose 之后」「同一帧里既改状态又换路由」——这些是框架的硬约束,早点知道能省下不少时间。这部分后来是我们一起查清的,他也把经验写进了代码注释。
- 本地仓库的同步习惯要跟上。他手上的 develop 一度落后远端好几个合并提交,导致本地看到的代码和仓库里不一致。开工前先
git pull这件事,值得变成肌肉记忆。
十、总结
这次结对下来,我最大的体会是:上一轮作业我们在证明「这个产品想清楚了」,这一轮在证明「它真的能跑」。
设计阶段可以横着铺功能,实现阶段必须竖着打通一条链路:用户点下「联系 TA」→ 消息落库 → 对方几秒内看到红点 → 物主把状态标成「已归还」→ 这条信息从首页、搜索、消息三处同时消失。这条链上任何一个环节用假数据糊过去,整个功能就是假的。我们花在「把假数据换成真接口」上的时间,比写那些接口本身还多——这是我这次印象最深的一课。
第二个体会是契约先行的价值被量化了。因为一开始就把 apifox_openapi.json 定死,两个人可以完全并行:我写物品和鉴权,他写消息和搜索,中间几乎不需要互相等。当然契约也不是万能的——我们仍然踩到了参数名不一致(困难五)这种「契约写了但代码没对齐」的坑,那是执行力问题,不是方法问题。
最后一个体会跟技术无关:联调后期的那些崩溃,跟业务逻辑一点关系都没有。 '_dependents.isEmpty'、Duplicate GlobalKeys,全是「同一帧里改状态、换路由、通知监听者」的时序问题。写业务的时候我们几乎没为框架层面留过心,代价就是最后被这些问题反复绊住。下一轮如果再做一个类似的 App,我想在一开始就先定两条规矩:所有 controller / listener 的生命周期一律挂在 State 上;状态变更和路由切换永远不放在同一帧。 这两句话能省掉的那部分时间,可能就是这次我们花在排查崩溃上的那些小时。

浙公网安备 33010602011771号