HarmonyOS宠物邻里实战第16篇:寄养申请状态机、记录时间线与回归测试

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 风险主要有三类:

  1. 旧版本 App 仍写入老状态字段,新版本后端只识别新字段;
  2. MongoDB Driver 升级后返回结构变化,导致 result.value 为空;
  3. 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, '无权修改该寄养申请');
}

这段代码表达了业务规则:

场景 谁能操作
主动申请取消 申请人
主动申请接受/拒绝 需求发布者
邀请接受/拒绝 被邀请人

集成测试要覆盖第三方用户越权修改申请状态。

五、接受申请后的连锁动作

申请被接受后,后端做三件事:

  1. 其他待处理申请自动拒绝;
  2. 寄养需求进入 waitingIn;
  3. 创建寄养记录。

代码如下:

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,前端两个页面会互相矛盾。

十二、端到端测试流程

测试流程建议如下:

  1. 注册 owner、asker、third;
  2. owner 创建宠物;
  3. owner 创建寄养需求;
  4. asker 提交寄养申请;
  5. third 尝试接受申请,断言 403;
  6. owner 接受申请,断言 200;
  7. 验证其他 pending 申请被拒绝;
  8. 验证 fosterRecord 自动生成;
  9. owner 直接改 done,断言 409;
  10. asker 改 ongoing,断言 200;
  11. asker 回退 waitingIn,断言 409;
  12. owner 改 done,断言 200;
  13. 验证 timeline 有"寄养开始"和"寄养完成";
  14. 验证 /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 页面刷新验证,寄养业务才能从"能点按钮"变成"状态可信"。

相关推荐
梦想不只是梦与想3 小时前
HarmonyOS应用分层架构设计
harmonyos·分层架构·一次开发,多端部署
MardaWang5 小时前
当滚动逃离了框架 ——HarmonyOS Web 与原生混排滚动的冲突本质与解法
harmonyos·arkts·鸿蒙·deveco studio
tsqtsqtsq03097 小时前
DevEco Studio 介绍
harmonyos
HwJack2010 小时前
【共创稿事节】HarmonyOS 7文旅展陈展厅大空间 3DGS 重建的分块策略与拼接踩坑
3d·华为·harmonyos
m0_7381858212 小时前
Flutter 鸿蒙化实战:media_info 适配 OpenHarmony,媒体信息与缩略图
flutter·华为·harmonyos·鸿蒙·媒体
m0_7381858213 小时前
Flutter 鸿蒙化实战:just_audio 适配 OpenHarmony,功能强大的播放器
flutter·华为·harmonyos·鸿蒙
翼辉cto13 小时前
Kotlin Multiplatform 三方库 SQLDelight 的 OpenHarmony 鸿蒙化适配实战
开发语言·kotlin·harmonyos
SuperHeroWu713 小时前
TraeCode 国内版接入 DevEco CLI:用官方知识开发鸿蒙应用
ai编程·harmonyos·知识库·trae·aicoding·skills·deveco cli
2501_9197490313 小时前
华为鸿蒙免费口算练习APP—小羊口算
华为·harmonyos·鸿蒙