HarmonyOS宠物邻里实战第15篇:寄养留言权限、回复锁与通知回归测试
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 check、npm 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()
});
}
通知里必须带 requestId 和 messageId,这样前端后续可以跳转到寄养详情并定位留言。
五、回复接口
回复接口先找留言,再找对应需求:
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 |
测试时两个方向都要断言。
八、端到端测试流程
推荐测试流程:
- 启动 Express 测试服务;
- 注册 owner、asker、third;
- owner 创建宠物;
- owner 创建寄养需求;
- owner 尝试顶层留言,断言 403;
- asker 顶层留言,断言 200;
- 检查 owner 收到留言通知;
- third 尝试回复,断言 403;
- owner 回复,断言 200;
- owner 重复回复,断言 409;
- 检查 asker 收到回复通知;
- 直连 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 收到通知;
- 留言通知包含
requestId和messageId; - third 回复返回 403;
- owner 回复返回 200;
- 重复回复返回 409;
- 回复成功后 asker 收到通知;
- MongoDB 中
fosterMessages.reply写入正确; - 前端留言区能刷新回复内容;
- 后端失败时前端能回滚;
- 集成测试结束后清理测试账号和关联数据。
总结
寄养留言区是宠物邻里项目中典型的权限型交互。它不像普通评论区那样所有人都能平等回复,而是要求咨询者发顶层留言,需求发布者逐条回复,第三方不能越权,同一条留言只能回复一次。
后端通过 request.authorId、message.authorId、reply: { $exists: false } 和通知写入规则保证业务正确;HarmonyOS 前端通过留言组件回调、本地状态刷新和后续 BackendService 接入完成交互闭环。配合端到端测试覆盖 200、403、404、409 和 MongoDB 断言,寄养留言功能才能真正稳定。
更多推荐



所有评论(0)