HarmonyOS宠物邻里实战第15篇:寄养留言权限、回复锁与通知回归测试

摘要

寄养业务里的留言区不是普通评论区。宠物主人发布寄养需求后,其他用户可以留言咨询;需求发布者不能给自己的需求发顶层留言,但可以逐条回复咨询;第三方用户不能替发布者回复;同一条留言只能回复一次。这些规则如果只靠前端按钮隐藏,很容易被绕过。

本文基于宠物邻里 HarmonyOS + Express + MongoDB 项目,复盘寄养留言的权限闭环:

  • POST /foster-requests/:id/messages 如何限制需求发布者;
  • POST /foster-messages/:id/reply 如何限制只有发布者能回复;
  • 为什么同一条留言只能回复一次;
  • 留言和回复如何生成通知;
  • HarmonyOS 端 FosterMessageBoard 如何把留言 ID 传回页面;
  • 如何用集成测试验证 200、403、404、409;
  • 如何直接查询 MongoDB 验证 reply、notice 和 requestId;
  • 最后给出寄养留言发布前验收清单。

这篇文章关注的是权限和状态锁:谁能说话、谁能回复、回复后还能不能再改。

工程背景与版本信息

文件 作用
houduan/test/routes/api.js 寄养留言、回复和通知接口
houduan/test/test/integration.js 端到端集成测试
houduan/test/db.js MongoDB 连接
MyApp/entry/src/main/ets/pages/foster/FosterRequestDetailPage.ets 寄养需求详情
MyApp/entry/src/main/ets/components/foster/FosterMessageBoard.ets 留言区组件
MyApp/entry/src/main/ets/components/foster/FosterMessageItem.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

后端 package.json 的关键脚本如下:

{
  "scripts": {
    "check": "node --check app.js && node --check db.js && node --check seed.js && node --check middleware/security.js && node --check routes/api.js && node --check routes/admin.js && node --check test/integration.js",
    "test:integration": "node ./test/integration.js"
  },
  "dependencies": {
    "express": "~4.16.1",
    "mongodb": "^4.17.2",
    "cookie-parser": "~1.4.4",
    "morgan": "~1.9.1"
  }
}
cd D:\APP\chong_wu_guan_li\houduan\test
npm run check
npm run test:integration

宠物邻里寄养留言权限回归测试预览

接口契约总览

接口 方法 鉴权 成功状态 失败状态
/foster-requests/:id/messages POST Bearer Token 200 400、403、404
/foster-messages/:id/reply POST Bearer Token 200 400、403、404、409
/bootstrap GET Bearer Token 200 401

返回结构统一使用:

function ok(res, data, msg = 'ok') {
  res.json({ code: 0, msg, data });
}

function fail(res, status, msg) {
  res.status(status).json({ code: status, msg, data: null });
}

前端 ApiClient 和测试脚本都按这个结构解析。这样 HarmonyOS 页面不需要同时兼容多种响应格式。

一、寄养留言的角色模型

寄养留言至少有三个角色:

角色 行为
需求发布者 owner 发布寄养需求,回复其他用户留言
咨询者 asker 给寄养需求发顶层留言
第三方 third 只能浏览,不能替发布者回复

权限规则如下:

场景 预期
owner 给自己需求发顶层留言 403
asker 给需求发留言 200
third 回复 asker 的留言 403
owner 回复 asker 的留言 200
owner 重复回复同一条留言 409
留言不存在 404
需求不存在 404

这些规则都必须在后端验证。

二、测试数据模型

寄养留言测试至少要准备这些业务对象:

数据 ID 示例 说明
owner foster_owner_12345678 需求发布者
asker foster_asker_12345678 咨询留言者
third foster_third_12345678 越权验证用户
pet foster_msg_pet_12345678 owner 的宠物
request foster_msg_request_12345678 寄养需求
message foster_msg_12345678 asker 的留言
notice n_... 留言和回复通知

命名里带时间后缀,是为了避免重复跑测试时 ID 冲突。测试结束后再用账号注销接口清理测试账号和关联数据。

三、顶层留言接口

后端接口:

router.post('/foster-requests/:id/messages', async function(req, res, next) {
  try {
    const db = getDatabase();
    const request = await db.collection('fosterRequests').findOne({ id: req.params.id });
    if (!request) {
      return fail(res, 404, '寄养需求不存在');
    }
    if (request.authorId === req.auth.profileId) {
      return fail(res, 403, '需求发布者只能逐条回复其他用户留言');
    }
    const content = text(req.body.content, 1000);
    if (!content) {
      return fail(res, 400, '留言内容不能为空');
    }
    const message = {
      id: text(req.body.id, 80) || `fm_${Date.now()}`,
      requestId: request.id,
      authorId: req.auth.profileId,
      content,
      publishedAt: '刚刚',
      createdAt: new Date()
    };
    await db.collection('fosterMessages').insertOne(message);
    ok(res, withoutMongoId(message), '留言已发布');
  } catch (error) {
    next(error);
  }
});

核心权限是:

if (request.authorId === req.auth.profileId) {
  return fail(res, 403, '需求发布者只能逐条回复其他用户留言');
}

发布者不能用顶层留言绕开回复结构,否则留言区会混乱。

四、留言通知生成

当咨询者留言时,系统通知需求发布者:

if (request.authorId !== req.auth.profileId) {
  const author = await db.collection('profiles').findOne({ id: req.auth.profileId });
  await db.collection('notices').insertOne({
    id: `n_${Date.now()}`,
    targetUserId: request.authorId,
    kind: 'system',
    fromUserId: req.auth.profileId,
    title: '寄养需求有新留言',
    text: `${author ? author.name : '邻里宠友'}留言咨询了“${request.title}`,
    time: '刚刚',
    unread: true,
    requestId: request.id,
    messageId: message.id,
    createdAt: new Date()
  });
}

通知里必须带 requestIdmessageId,这样前端后续可以跳转到寄养详情并定位留言。

五、回复接口

回复接口先找留言,再找对应需求:

router.post('/foster-messages/:id/reply', async function(req, res, next) {
  try {
    const db = getDatabase();
    const message = await db.collection('fosterMessages').findOne({ id: req.params.id });
    if (!message) {
      return fail(res, 404, '留言不存在');
    }
    const request = await db.collection('fosterRequests').findOne({ id: message.requestId });
    if (!request) {
      return fail(res, 404, '寄养需求不存在');
    }
    if (request.authorId !== req.auth.profileId) {
      return fail(res, 403, '只有需求发布者可以回复留言');
    }
    if (message.reply) {
      return fail(res, 409, '该留言已经回复');
    }
    const content = text(req.body.content, 1000);
    if (!content) {
      return fail(res, 400, '回复内容不能为空');
    }
  } catch (error) {
    next(error);
  }
});

权限规则很明确:

  • 留言不存在:404;
  • 需求不存在:404;
  • 当前用户不是需求发布者:403;
  • 已经回复过:409;
  • 内容为空:400。

六、回复锁:防止并发重复回复

接口最后用 findOneAndUpdate 写入回复:

const reply = {
  id: text(req.body.id, 80) || `fmr_${Date.now()}`,
  authorId: req.auth.profileId,
  content,
  publishedAt: '刚刚',
  createdAt: new Date()
};

const result = await db.collection('fosterMessages').findOneAndUpdate(
  { id: message.id, reply: { $exists: false } },
  { $set: { reply, updatedAt: new Date() } },
  { returnDocument: 'after' }
);
if (!result.value) {
  return fail(res, 409, '该留言已经回复');
}

这里有一个并发保护:更新条件里包含 reply: { $exists: false }。即使两个请求几乎同时回复同一条留言,也只有第一个能成功,第二个会返回 409。

七、回复通知生成

回复成功后,通知留言者:

if (message.authorId !== req.auth.profileId) {
  await db.collection('notices').insertOne({
    id: `n_${Date.now()}`,
    targetUserId: message.authorId,
    kind: 'system',
    fromUserId: req.auth.profileId,
    title: '寄养留言收到回复',
    text: `需求发布者回复了你在“${request.title}”下的留言`,
    time: '刚刚',
    unread: true,
    requestId: request.id,
    messageId: message.id,
    createdAt: new Date()
  });
}

回复通知和留言通知方向相反:

动作 通知接收者
asker 留言 owner
owner 回复 asker

测试时两个方向都要断言。

八、端到端测试流程

推荐测试流程:

  1. 启动 Express 测试服务;
  2. 注册 owner、asker、third;
  3. owner 创建宠物;
  4. owner 创建寄养需求;
  5. owner 尝试顶层留言,断言 403;
  6. asker 顶层留言,断言 200;
  7. 检查 owner 收到留言通知;
  8. third 尝试回复,断言 403;
  9. owner 回复,断言 200;
  10. owner 重复回复,断言 409;
  11. 检查 asker 收到回复通知;
  12. 直连 MongoDB 校验 reply 和 notices。

九、测试脚本骨架

const assert = require('assert');
const { spawn } = require('child_process');
const { MongoClient } = require('mongodb');

const port = 3113;
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() };
}

等待服务启动:

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function waitForServer() {
  for (let i = 0; i < 40; i++) {
    try {
      const response = await api('/health');
      if (response.status === 200) return;
    } catch (_error) {
      // Express may still be connecting to MongoDB.
    }
    await sleep(250);
  }
  throw new Error('Integration server did not become ready');
}

启动 Express 测试服务:

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
});

注册测试账号:

async function register(prefix) {
  const username = prefix + suffix;
  const response = await api('/auth/register', 'POST', '', { username, password });
  assert.strictEqual(response.status, 200, JSON.stringify(response.json));
  return {
    username,
    profileId: response.json.data.profileId,
    token: response.json.data.token
  };
}

const owner = await register('foster_owner_');
const asker = await register('foster_asker_');
const third = await register('foster_third_');

清理测试账号:

const createdAccounts = [];

async function cleanupAccounts() {
  for (const account of createdAccounts) {
    try {
      await api('/auth/delete-account', 'POST', account.token, {
        password,
        confirmation: '注销账号'
      });
    } catch (_error) {
      // Keep assertion failures as the primary signal.
    }
  }
}

十、创建宠物和寄养需求

const petId = `foster_msg_pet_${suffix}`;
const requestId = `foster_msg_request_${suffix}`;

let response = await api('/pets', 'POST', owner.token, {
  id: petId,
  name: '留言测试宠物',
  species: '猫',
  gender: 'female',
  ageDesc: '1岁'
});
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));

创建后先验证数据库中确实有需求:

let client = new MongoClient(process.env.MONGODB_URL || 'mongodb://127.0.0.1:27017');
await client.connect();
let db = client.db(process.env.MONGODB_DB || 'chongwu');
let storedRequest = await db.collection('fosterRequests').findOne({ id: requestId });
assert.strictEqual(storedRequest.authorId, owner.profileId);
assert.strictEqual(storedRequest.petId, petId);
await client.close();

十一、顶层留言权限测试

owner 不能给自己的需求发顶层留言:

response = await api(`/foster-requests/${requestId}/messages`, 'POST', owner.token, {
  content: '发布者不能这样留言'
});
assert.strictEqual(response.status, 403);

asker 可以留言:

const messageId = `foster_msg_${suffix}`;
response = await api(`/foster-requests/${requestId}/messages`, 'POST', asker.token, {
  id: messageId,
  content: '请问能否每天发一次宠物状态?'
});
assert.strictEqual(response.status, 200, JSON.stringify(response.json));
assert.strictEqual(response.json.data.id, messageId);

还要验证留言写入 MongoDB:

client = new MongoClient(process.env.MONGODB_URL || 'mongodb://127.0.0.1:27017');
await client.connect();
db = client.db(process.env.MONGODB_DB || 'chongwu');
const storedMessageBeforeReply = await db.collection('fosterMessages').findOne({ id: messageId });
assert.strictEqual(storedMessageBeforeReply.requestId, requestId);
assert.strictEqual(storedMessageBeforeReply.authorId, asker.profileId);
assert.strictEqual(Boolean(storedMessageBeforeReply.reply), false);
await client.close();

十二、留言通知断言

读取 owner 快照:

let snapshot = await api('/bootstrap', 'GET', owner.token);
const messageNotice = snapshot.json.data.notices.find((item) =>
  item.targetUserId === owner.profileId &&
  item.fromUserId === asker.profileId &&
  item.requestId === requestId &&
  item.messageId === messageId
);
assert.ok(messageNotice);
assert.strictEqual(messageNotice.unread, true);

也可以直接查 MongoDB:

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');
assert.ok(await db.collection('notices').findOne({
  targetUserId: owner.profileId,
  fromUserId: asker.profileId,
  requestId,
  messageId
}));
await client.close();

十三、回复权限测试

third 不能回复:

response = await api(`/foster-messages/${messageId}/reply`, 'POST', third.token, {
  content: '第三方不能回复'
});
assert.strictEqual(response.status, 403);

owner 可以回复:

response = await api(`/foster-messages/${messageId}/reply`, 'POST', owner.token, {
  content: '可以,每晚八点前同步照片和喂食情况。'
});
assert.strictEqual(response.status, 200, JSON.stringify(response.json));
assert.ok(response.json.data.reply);
assert.strictEqual(response.json.data.reply.authorId, owner.profileId);

重复回复应返回 409:

response = await api(`/foster-messages/${messageId}/reply`, 'POST', owner.token, {
  content: '重复回复'
});
assert.strictEqual(response.status, 409);

还要补一个空回复测试:

const emptyMessageId = `foster_empty_${suffix}`;
response = await api(`/foster-requests/${requestId}/messages`, 'POST', asker.token, {
  id: emptyMessageId,
  content: '用于空回复测试'
});
assert.strictEqual(response.status, 200, JSON.stringify(response.json));

response = await api(`/foster-messages/${emptyMessageId}/reply`, 'POST', owner.token, {
  content: ''
});
assert.strictEqual(response.status, 400);

十四、回复通知断言

读取 asker 快照:

snapshot = await api('/bootstrap', 'GET', asker.token);
const replyNotice = snapshot.json.data.notices.find((item) =>
  item.targetUserId === asker.profileId &&
  item.fromUserId === owner.profileId &&
  item.requestId === requestId &&
  item.messageId === messageId &&
  item.text.includes('回复')
);
assert.ok(replyNotice);
assert.strictEqual(replyNotice.unread, true);

MongoDB 校验:

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 storedMessage = await db2.collection('fosterMessages').findOne({ id: messageId });
assert.ok(storedMessage.reply);
assert.strictEqual(storedMessage.reply.authorId, owner.profileId);

const replyNoticeInDb = await db2.collection('notices').findOne({
  targetUserId: asker.profileId,
  requestId,
  messageId
});
assert.ok(replyNoticeInDb);

await client2.close();

十五、前端组件传参

FosterMessageBoard 会把 messageId 和回复内容传给页面:

FosterMessageItem({
  message,
  canReply: this.canReply,
  onReply: (messageId: string, content: string) => {
    this.onReply(messageId, content);
  }
})

详情页收到回调后,可以调用本地状态层:

onReply: (messageId: string, content: string) => {
  if (MockStore.replyFosterMessage(messageId, content) !== null) {
    this.refresh();
  }
}

后续接后端时,可以把 replyFosterMessage() 做成乐观更新:

static replyFosterMessage(messageId: string, content: string): FosterMessage | null {
  const message = MockStore.getFosterMessage(messageId);
  if (message === null || message.reply !== undefined) return null;
  const oldReply = message.reply;
  message.reply = new FosterReply(MockStore.meId, content);
  MockStore.bumpFosterVersion();
  BackendService.replyFosterMessage(messageId, content).catch((e: Error) => {
    message.reply = oldReply;
    MockStore.bumpFosterVersion();
    MockStore.reportSyncFailure('寄养留言回复失败,已恢复原状态', e);
  });
  return message;
}

十六、后端接口接入建议

如果前端要完全接入后端,可以在 BackendService 增加两个方法:

class FosterMessagePayload {
  id: string = '';
  content: string = '';
}

class FosterReplyPayload {
  content: string = '';
}

static async createFosterMessage(
  requestId: string,
  id: string,
  content: string
): Promise<void> {
  const payload: FosterMessagePayload = new FosterMessagePayload();
  payload.id = id;
  payload.content = content;
  BackendService.requireOk(await ApiClient.post(
    '/foster-requests/' + encodeURIComponent(requestId) + '/messages',
    payload
  ));
}

static async replyFosterMessage(messageId: string, content: string): Promise<void> {
  const payload: FosterReplyPayload = new FosterReplyPayload();
  payload.content = content;
  BackendService.requireOk(await ApiClient.post(
    '/foster-messages/' + encodeURIComponent(messageId) + '/reply',
    payload
  ));
}

这里必须使用 encodeURIComponent(),避免 ID 中出现特殊字符时破坏 URL。

十七、数据库验收 SQL 思路

虽然 MongoDB 不是 SQL,但可以把验收逻辑拆成几类查询:

验收目标 MongoDB 查询
留言存在 fosterMessages.findOne({ id: messageId })
回复写入 fosterMessages.findOne({ id: messageId, reply: { $exists: true } })
owner 收到留言通知 notices.findOne({ targetUserId: owner.profileId, messageId })
asker 收到回复通知 notices.findOne({ targetUserId: asker.profileId, messageId })
third 没有通知 notices.countDocuments({ targetUserId: third.profileId, messageId }) === 0

完整断言:

client = new MongoClient(process.env.MONGODB_URL || 'mongodb://127.0.0.1:27017');
await client.connect();
db = client.db(process.env.MONGODB_DB || 'chongwu');

assert.ok(await db.collection('fosterMessages').findOne({
  id: messageId,
  requestId,
  authorId: asker.profileId,
  'reply.authorId': owner.profileId
}));

assert.ok(await db.collection('notices').findOne({
  targetUserId: owner.profileId,
  fromUserId: asker.profileId,
  requestId,
  messageId
}));

assert.ok(await db.collection('notices').findOne({
  targetUserId: asker.profileId,
  fromUserId: owner.profileId,
  requestId,
  messageId
}));

assert.strictEqual(await db.collection('notices').countDocuments({
  targetUserId: third.profileId,
  messageId
}), 0);

await client.close();

十八、常见问题排查

问题 可能原因 排查方式
owner 能发顶层留言 缺少 request.authorId === req.auth.profileId 校验 用 owner token 测试
third 能回复留言 回复接口没校验 request.authorId 用 third token 测试
同一留言能回复多次 更新条件没有 reply: { $exists: false } 并发或重复请求测试
留言后 owner 没通知 notices 没写入或 targetUserId 错 notices.requestId/messageId
回复后 asker 没通知 回复通知 target 写错 targetUserId === message.authorId
前端回复后列表不刷新 没有 bump fosterVersion 检查本地状态层
回复失败后仍显示成功 乐观更新没有回滚 检查 catch 分支

十九、发布前验收清单

  • 需求不存在返回 404;
  • owner 顶层留言返回 403;
  • asker 顶层留言返回 200;
  • 留言成功后 owner 收到通知;
  • 留言通知包含 requestIdmessageId
  • third 回复返回 403;
  • owner 回复返回 200;
  • 重复回复返回 409;
  • 回复成功后 asker 收到通知;
  • MongoDB 中 fosterMessages.reply 写入正确;
  • 前端留言区能刷新回复内容;
  • 后端失败时前端能回滚;
  • 集成测试结束后清理测试账号和关联数据。

总结

寄养留言区是宠物邻里项目中典型的权限型交互。它不像普通评论区那样所有人都能平等回复,而是要求咨询者发顶层留言,需求发布者逐条回复,第三方不能越权,同一条留言只能回复一次。

后端通过 request.authorIdmessage.authorIdreply: { $exists: false } 和通知写入规则保证业务正确;HarmonyOS 前端通过留言组件回调、本地状态刷新和后续 BackendService 接入完成交互闭环。配合端到端测试覆盖 200、403、404、409 和 MongoDB 断言,寄养留言功能才能真正稳定。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐