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 checknpm 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 findOneAndUpdatereturnDocument: 'after' 在驱动版本变化时要重点回归
Node.js 项目本地测试环境 Node 版本过旧时可能没有全局 fetch,需要引入 node-fetch 或升级运行时
状态字段 waitingInongoingdone 如果历史数据仍使用旧字段,要做迁移映射,不要直接上线新状态机

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 pendingacceptedrejectedcancelled
记录状态 fosterRecords waitingInongoingdone

状态关系如下:

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.statusfosterRecords.status 保持一致;
  • 未完成记录不能评价;
  • 集成测试结束后清理测试账号和关联数据。

总结

寄养申请状态机是宠物邻里项目的核心业务骨架。申请被接受后,后端不仅要修改申请状态,还要拒绝其他待处理申请、更新需求状态、创建寄养记录、通知申请人;寄养记录后续只能按 waitingIn -> ongoing -> done 顺序推进,并在时间线里留下"寄养开始"和"寄养完成"。

这条链路如果没有集成测试,很容易在前端联调时变成一堆难排查的状态错乱。通过 Express 路由校验、MongoDB 断言、/bootstrap 快照检查和 HarmonyOS 页面刷新验证,寄养业务才能从"能点按钮"变成"状态可信"。

相关推荐
不羁的木木1 小时前
HarmonyOS APP实战-画图APP - 第6篇:形状切换与UI优化
ui·华为·harmonyos
xd1855785551 小时前
鸿蒙PC平台Flutter魔术教学应用开发实践:打造跨端沉浸式魔术学习体验
人工智能·学习·flutter·华为·harmonyos·鸿蒙
●VON2 小时前
HarmonyKit | 移动端开发者工具类应用的现状与趋势
git·华为·性能优化·harmonyos·鸿蒙
不羁的木木2 小时前
HarmonyOS APP实战-画图APP - 第10篇:色彩管理功能
华为·harmonyos
●VON2 小时前
HarmonyKit | 鸿蒙开发协作:如何为 HarmonyKit 贡献一个新的工具
git·华为·harmonyos·鸿蒙
2301_768103492 小时前
HarmonyOS趣味相机实战第1篇:工程结构、相机权限申请与真实拍摄主流程
harmonyos·arkts·项目实战·水印相机·camerakit
木木子222 小时前
# [特殊字符] 养猫模拟器 — 鸿蒙ArkTS完整技术解析
华为·harmonyos
anling_li2 小时前
《图片华容道》三、HarmonyOS_ArkTS开发调试实战_崩溃修复全记录
ui·华为·harmonyos
千逐682 小时前
鸿蒙实战:元服务与卡片开发全攻略
华为·harmonyos·鸿蒙