HarmonyOS宠物邻里实战第16篇:寄养申请状态机、记录时间线与回归测试
摘要
宠物寄养业务不是简单的"提交申请"。完整链路应该包含:宠物主人发布需求、寄养人申请、主人接受申请、系统生成寄养记录、寄养开始、寄养完成、宠物主人评价。每一步都有角色权限和状态顺序,如果后端不控制,前端页面很容易出现"跳步完成""状态回退""多个申请同时被接受"等问题。
本文基于宠物邻里 HarmonyOS + Express + MongoDB 项目,复盘寄养申请状态机的完整工程闭环:
fosterApplications如何从pending变为accepted;- 接受一个申请后,其他待处理申请为什么要自动拒绝;
fosterRecords如何按rec_${requestId}自动生成;- 记录状态为什么只能
waitingIn -> ongoing -> done; - 时间线
timeline如何记录"寄养开始"和"寄养完成"; - 集成测试如何覆盖 200、403、404、409;
- HarmonyOS 前端如何用寄养中心、详情页和记录页展示状态;
- 最后给出发布前验收清单。
这篇文章重点是状态机。业务状态一旦失控,前端再漂亮也只是显示错误数据。
工程背景与版本信息
| 文件 | 作用 |
|---|---|
houduan/test/routes/api.js |
寄养申请、记录状态、评价接口 |
houduan/test/test/integration.js |
端到端集成测试 |
houduan/test/db.js |
MongoDB 连接 |
MyApp/entry/src/main/ets/pages/foster/FosterCenterPage.ets |
我的寄养中心 |
MyApp/entry/src/main/ets/pages/foster/FosterRecordDetailPage.ets |
寄养记录详情和时间线 |
MyApp/entry/src/main/ets/components/foster/FosterManageCards.ets |
申请管理卡片 |
MyApp/entry/src/main/ets/common/MockStore.ets |
本地寄养数据和版本刷新 |
MyApp/entry/src/main/ets/services/BackendService.ets |
后端接口语义层 |
环境信息:
| 项目 | 值 |
|---|---|
| HarmonyOS 工程模型 | modelVersion: 6.0.2 |
| target SDK | 6.0.2(22) |
| 后端框架 | Express ~4.16.1 |
| MongoDB Driver | ^4.17.2 |
| 测试命令 | npm run check、npm run test:integration |
版本兼容性与 EOL 风险说明
这条寄养状态机链路基于当前项目环境验证,不建议脱离版本背景直接照搬:
| 模块 | 当前项目使用方式 | 兼容性注意点 |
|---|---|---|
| HarmonyOS | modelVersion: 6.0.2、target SDK 6.0.2(22) |
页面状态刷新依赖 AppStorage、@StorageLink、@Watch,升级 SDK 后要回归 Watch 触发时机 |
| Express | ~4.16.1 |
如果迁移到 Express 5,需要重新检查异步错误处理和中间件返回行为 |
| MongoDB Driver | ^4.17.2 |
findOneAndUpdate 的 returnDocument: 'after' 在驱动版本变化时要重点回归 |
| Node.js | 项目本地测试环境 | Node 版本过旧时可能没有全局 fetch,需要引入 node-fetch 或升级运行时 |
| 状态字段 | waitingIn、ongoing、done |
如果历史数据仍使用旧字段,要做迁移映射,不要直接上线新状态机 |
EOL 风险主要有三类:
- 旧版本 App 仍写入老状态字段,新版本后端只识别新字段;
- MongoDB Driver 升级后返回结构变化,导致
result.value为空; - Express 版本升级后异步异常没有进入统一错误处理,接口返回不稳定。
上线前建议保留一段兼容映射:
js
function normalizeRecordStatus(value) {
if (value === 'waiting' || value === 'waitIn') return 'waitingIn';
if (value === 'processing') return 'ongoing';
if (value === 'finished' || value === 'completed') return 'done';
return value;
}
如果数据库已有旧记录,可以先跑一次迁移脚本:
js
await db.collection('fosterRecords').updateMany(
{ status: 'finished' },
{ $set: { status: 'done', migratedAt: new Date() } }
);
这类版本边界要写进回归测试说明里。状态机文章最怕读者直接复制字段,却忽略自己项目里已有的历史状态。
powershell
cd D:\APP\chong_wu_guan_li\houduan\test
npm run check
npm run test:integration

一、状态机总览
寄养业务至少有两个状态模型:
| 模型 | 集合 | 状态 |
|---|---|---|
| 申请状态 | fosterApplications |
pending、accepted、rejected、cancelled |
| 记录状态 | fosterRecords |
waitingIn、ongoing、done |
状态关系如下:
text
pending application
-> accepted
-> create fosterRecord(waitingIn)
-> ongoing
-> done
-> review
这条链路不能跳步。比如不能在还没开始寄养时直接完成,也不能从 ongoing 回退到 waitingIn。
二、申请创建后的默认状态
寄养申请创建时,默认状态是 pending:
js
const application = {
id: text(req.body.id, 80) || `fa_${Date.now()}`,
requestId: req.params.id,
applicantId,
ownerId: request.authorId,
status: 'pending',
source: 'application',
message: applicationMessage,
createdAt: new Date()
};
await db.collection('fosterApplications').insertOne(application);
同时生成通知给需求发布者:
js
await db.collection('notices').insertOne({
id: `n_${Date.now()}`,
targetUserId: request.authorId,
kind: 'system',
fromUserId: applicantId,
title: '新的寄养申请',
text: `有人申请了"${request.title}"`,
time: '刚刚',
unread: true,
createdAt: new Date()
});
这一步保证宠物主人能在通知中心看到新申请。
三、申请状态接口
申请状态更新接口先校验状态值:
js
const status = text(req.body.status, 20) || '';
if (!['pending', 'accepted', 'rejected', 'cancelled'].includes(status)) {
return fail(res, 400, '申请状态不正确');
}
然后查询申请:
js
const existing = await db.collection('fosterApplications').findOne({ id: req.params.id });
if (!existing) {
return fail(res, 404, '寄养申请不存在');
}
这两个校验是基础防线。不存在的申请不能更新,未知状态不能进入数据库。
四、角色权限判断
申请可能来自两种来源:
application:寄养人主动申请;invitation:宠物主人邀请寄养人。
接口根据来源判断权限:
js
const isInvitation = existing.source === 'invitation';
const isApplicantAction = existing.applicantId === req.auth.profileId &&
(status === 'cancelled' || (isInvitation && ['accepted', 'rejected'].includes(status)));
const isOwnerAction = !isInvitation && ['accepted', 'rejected'].includes(status) &&
existing.ownerId === req.auth.profileId;
if (!isApplicantAction && !isOwnerAction) {
return fail(res, 403, '无权修改该寄养申请');
}
这段代码表达了业务规则:
| 场景 | 谁能操作 |
|---|---|
| 主动申请取消 | 申请人 |
| 主动申请接受/拒绝 | 需求发布者 |
| 邀请接受/拒绝 | 被邀请人 |
集成测试要覆盖第三方用户越权修改申请状态。
五、接受申请后的连锁动作
申请被接受后,后端做三件事:
- 其他待处理申请自动拒绝;
- 寄养需求进入
waitingIn; - 创建寄养记录。
代码如下:
js
if (status === 'accepted') {
const application = result.value;
const request = await db.collection('fosterRequests').findOne({ id: application.requestId });
if (request) {
await db.collection('fosterApplications').updateMany(
{ requestId: application.requestId, id: { $ne: application.id }, status: 'pending' },
{ $set: { status: 'rejected', updatedAt: new Date() } }
);
await db.collection('fosterRequests').updateOne(
{ id: application.requestId },
{ $set: { status: 'waitingIn', updatedAt: new Date() } }
);
}
}
这个连锁动作很重要。一个寄养需求通常只能接受一个寄养人,其他待处理申请必须自动变成 rejected。
六、自动创建寄养记录
接受申请后创建记录:
js
await db.collection('fosterRecords').updateOne(
{ id: `rec_${application.requestId}` },
{
$setOnInsert: {
id: `rec_${application.requestId}`,
requestId: application.requestId,
petId: request.petId,
hostId: application.applicantId,
startDate: request.startDate,
endDate: request.endDate,
location: request.location,
status: 'waitingIn',
cover: request.cover,
timeline: [],
createdAt: new Date()
}
},
{ upsert: true }
);
这里用 upsert 和固定 ID rec_${requestId},可以防止重复接受时产生多条记录。记录初始状态是 waitingIn,表示等待入住。
七、接受通知
接受申请后通知申请人:
js
await db.collection('notices').insertOne({
id: `n_${Date.now()}`,
targetUserId: application.applicantId,
kind: 'system',
fromUserId: application.ownerId,
title: '寄养申请已通过',
text: `你对"${request.title}"的申请已通过`,
time: '刚刚',
unread: true,
createdAt: new Date()
});
这条通知让寄养人知道下一步该准备接宠物。
八、记录状态接口
记录状态只允许三个值:
js
const status = text(req.body.status, 20) || '';
if (!['waitingIn', 'ongoing', 'done'].includes(status)) {
return fail(res, 400, '寄养状态不正确');
}
先查询记录:
js
const record = await db.collection('fosterRecords').findOne({ id: req.params.id });
if (!record) {
return fail(res, 404, '寄养记录不存在');
}
然后判断当前用户是不是参与者:
js
const pet = await db.collection('pets').findOne({ id: record.petId });
const isParticipant = record.hostId === req.auth.profileId ||
(pet && pet.ownerId === req.auth.profileId);
if (!isParticipant) {
return fail(res, 403, '无权修改该寄养记录');
}
只有寄养人或宠物主人可以推进状态。
九、状态顺序限制
状态不能乱跳:
js
const allowedNextStatus = {
waitingIn: 'ongoing',
ongoing: 'done'
};
if (allowedNextStatus[record.status] !== status) {
return fail(res, 409, '寄养状态只能按待入住、寄养中、已完成顺序推进');
}
这段代码保证:
| 当前状态 | 允许下一步 |
|---|---|
waitingIn |
ongoing |
ongoing |
done |
done |
无 |
如果从 waitingIn 直接改 done,返回 409;如果从 ongoing 回到 waitingIn,也返回 409。
十、时间线写入
状态推进时写入时间线:
js
const timelineText = status === 'ongoing' ? '寄养开始' : '寄养完成';
const timelineItem = {
date: new Date().toLocaleString('zh-CN', { hour12: false }),
text: timelineText,
photos: []
};
更新记录:
js
const result = await db.collection('fosterRecords').findOneAndUpdate(
{ id: req.params.id, status: record.status },
{
$set: { status, updatedAt: new Date() },
$push: { timeline: timelineItem }
},
{ returnDocument: 'after' }
);
更新条件里带旧状态 status: record.status,可以防止并发请求重复推进。
十一、同步更新需求状态
记录状态变化后,寄养需求也要同步:
js
if (record.requestId) {
await db.collection('fosterRequests').updateOne(
{ id: record.requestId },
{ $set: { status, updatedAt: new Date() } }
);
}
否则寄养记录显示 done,寄养需求列表还显示 waitingIn,前端两个页面会互相矛盾。
十二、端到端测试流程
测试流程建议如下:
- 注册 owner、asker、third;
- owner 创建宠物;
- owner 创建寄养需求;
- asker 提交寄养申请;
- third 尝试接受申请,断言 403;
- owner 接受申请,断言 200;
- 验证其他 pending 申请被拒绝;
- 验证
fosterRecord自动生成; - owner 直接改 done,断言 409;
- asker 改 ongoing,断言 200;
- asker 回退 waitingIn,断言 409;
- owner 改 done,断言 200;
- 验证 timeline 有"寄养开始"和"寄养完成";
- 验证
/bootstrap快照状态一致。
十三、测试脚本骨架
js
const assert = require('assert');
const { spawn } = require('child_process');
const { MongoClient } = require('mongodb');
const port = 3114;
const baseUrl = `http://127.0.0.1:${port}/api`;
const password = 'TestPass123';
const suffix = String(Date.now()).slice(-8);
async function api(path, method = 'GET', token = '', body) {
const response = await fetch(baseUrl + path, {
method,
headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {})
},
body: body === undefined ? undefined : JSON.stringify(body)
});
return { status: response.status, json: await response.json() };
}
测试服务启动:
js
const server = spawn(process.execPath, ['./bin/www'], {
cwd: __dirname + '/..',
env: {
...process.env,
PORT: String(port),
HOST: '127.0.0.1',
API_RATE_LIMIT: '1000',
LOGIN_RATE_LIMIT: '100',
REGISTER_RATE_LIMIT: '100'
},
stdio: 'ignore',
windowsHide: true
});
十四、创建基础数据
js
const owner = await register('status_owner_');
const asker = await register('status_asker_');
const third = await register('status_third_');
const petId = `status_pet_${suffix}`;
const requestId = `status_request_${suffix}`;
const applicationId = `status_application_${suffix}`;
let response = await api('/pets', 'POST', owner.token, {
id: petId,
name: '状态测试宠物',
species: '犬',
gender: 'male',
ageDesc: '2岁'
});
assert.strictEqual(response.status, 200, JSON.stringify(response.json));
response = await api('/foster-requests', 'POST', owner.token, {
id: requestId,
title: '寄养状态机测试',
petId,
fosterType: '家庭寄养',
startDate: '2026-07-10',
endDate: '2026-07-12',
location: '上海市 徐汇区',
latitude: 31.1884,
longitude: 121.4368,
budget: '100元/天',
feedRequirement: '每日两次',
walkRequirement: '每日两次',
notesRequirement: '需要按时反馈状态'
});
assert.strictEqual(response.status, 200, JSON.stringify(response.json));
十五、申请和接受测试
js
response = await api(`/foster-requests/${requestId}/applications`, 'POST', asker.token, {
id: applicationId,
message: '我可以按要求照顾。'
});
assert.strictEqual(response.status, 200, JSON.stringify(response.json));
response = await api(`/foster-applications/${applicationId}/status`, 'PUT', third.token, {
status: 'accepted'
});
assert.strictEqual(response.status, 403);
response = await api(`/foster-applications/${applicationId}/status`, 'PUT', owner.token, {
status: 'accepted'
});
assert.strictEqual(response.status, 200, JSON.stringify(response.json));
直接查询 MongoDB:
js
const client = new MongoClient(process.env.MONGODB_URL || 'mongodb://127.0.0.1:27017');
await client.connect();
const db = client.db(process.env.MONGODB_DB || 'chongwu');
const application = await db.collection('fosterApplications').findOne({ id: applicationId });
assert.strictEqual(application.status, 'accepted');
const request = await db.collection('fosterRequests').findOne({ id: requestId });
assert.strictEqual(request.status, 'waitingIn');
const recordId = `rec_${requestId}`;
const record = await db.collection('fosterRecords').findOne({ id: recordId });
assert.strictEqual(record.status, 'waitingIn');
assert.strictEqual(record.hostId, asker.profileId);
await client.close();
十六、状态推进测试
不能直接完成:
js
const recordId = `rec_${requestId}`;
response = await api(`/foster-records/${recordId}/status`, 'PUT', owner.token, {
status: 'done'
});
assert.strictEqual(response.status, 409);
寄养人推进到 ongoing:
js
response = await api(`/foster-records/${recordId}/status`, 'PUT', asker.token, {
status: 'ongoing'
});
assert.strictEqual(response.status, 200, JSON.stringify(response.json));
assert.strictEqual(response.json.data.status, 'ongoing');
不能回退:
js
response = await api(`/foster-records/${recordId}/status`, 'PUT', asker.token, {
status: 'waitingIn'
});
assert.strictEqual(response.status, 409);
宠物主人确认完成:
js
response = await api(`/foster-records/${recordId}/status`, 'PUT', owner.token, {
status: 'done'
});
assert.strictEqual(response.status, 200, JSON.stringify(response.json));
assert.strictEqual(response.json.data.status, 'done');
十七、时间线断言
js
const client2 = new MongoClient(process.env.MONGODB_URL || 'mongodb://127.0.0.1:27017');
await client2.connect();
const db2 = client2.db(process.env.MONGODB_DB || 'chongwu');
const finalRecord = await db2.collection('fosterRecords').findOne({ id: recordId });
assert.strictEqual(finalRecord.status, 'done');
assert.ok(finalRecord.timeline.some((item) => item.text === '寄养开始'));
assert.ok(finalRecord.timeline.some((item) => item.text === '寄养完成'));
const finalRequest = await db2.collection('fosterRequests').findOne({ id: requestId });
assert.strictEqual(finalRequest.status, 'done');
await client2.close();
这组断言证明记录状态和需求状态同步完成。
十八、前端展示闭环
FosterCenterPage 会读取我的申请和记录:
ts
private refresh(): void {
this.requests = MockStore.myFosterRequests();
this.applications = MockStore.myFosterApplications();
this.invitations = MockStore.incomingFosterInvitations();
this.records = MockStore.fosterRecords.filter((record: FosterRecord) => {
const pet = MockStore.getPet(record.petId);
return record.hostId === MockStore.meId || (pet !== null && pet.ownerId === MockStore.meId);
});
}
状态改变后要刷新 fosterVersion:
ts
@StorageLink('fosterVersion') @Watch('onFosterChanged') fosterVersion: number = 0;
onFosterChanged(): void {
this.refresh();
}
这样申请状态、记录状态和时间线才能及时展示。
十九、评价前置条件
评价接口要求记录必须完成:
js
if (record.status !== 'done') {
return fail(res, 400, '寄养结束后才能评价');
}
只有宠物主人可以评价:
js
const pet = await db.collection('pets').findOne({ id: record.petId, ownerId: authorId });
if (!pet) {
return fail(res, 403, '只有宠物主人可以评价寄养服务');
}
这说明状态机不仅影响记录详情,还影响后续评价入口。
二十、常见问题排查
| 问题 | 可能原因 | 排查方式 |
|---|---|---|
| 第三方能接受申请 | 权限判断漏了 ownerId | 用 third token 测试 |
| 接受申请后没有记录 | fosterRecords.updateOne 没执行 |
查 rec_${requestId} |
| 多个申请同时 pending | 接受后没拒绝其他申请 | 查同 requestId 的 pending |
| 直接 done 成功 | 状态机没限制顺序 | 测 waitingIn -> done |
| 时间线为空 | 更新时没 $push timeline |
查 fosterRecords.timeline |
| 需求状态和记录状态不一致 | 未同步更新 fosterRequests | 查两个集合 status |
| 前端寄养中心不刷新 | 没 bump fosterVersion | 查 AppStorage |
| 未完成也能评价 | 评价接口未校验 done | 测 ongoing review |
二十一、发布前验收清单
- 申请创建后状态为
pending; - 非相关用户不能接受申请;
- owner 能接受主动申请;
- 接受后其他 pending 申请自动 rejected;
- 接受后需求状态为
waitingIn; - 接受后自动创建
fosterRecord; - 记录初始状态为
waitingIn; waitingIn -> done返回 409;waitingIn -> ongoing成功;ongoing -> waitingIn返回 409;ongoing -> done成功;- 时间线包含"寄养开始"和"寄养完成";
fosterRequests.status和fosterRecords.status保持一致;- 未完成记录不能评价;
- 集成测试结束后清理测试账号和关联数据。
总结
寄养申请状态机是宠物邻里项目的核心业务骨架。申请被接受后,后端不仅要修改申请状态,还要拒绝其他待处理申请、更新需求状态、创建寄养记录、通知申请人;寄养记录后续只能按 waitingIn -> ongoing -> done 顺序推进,并在时间线里留下"寄养开始"和"寄养完成"。
这条链路如果没有集成测试,很容易在前端联调时变成一堆难排查的状态错乱。通过 Express 路由校验、MongoDB 断言、/bootstrap 快照检查和 HarmonyOS 页面刷新验证,寄养业务才能从"能点按钮"变成"状态可信"。