【寻迹校园 HarmonyOS NEXT 实战 31】举报去重怎么做:同一记录的重复投诉不应制造多条处理中案件
本章导读:这是"寻迹校园 HarmonyOS NEXT 实战"系列第 31 篇。本文结合
ModerationReportPage、ModerationService.submit()、ModerationRepository.findByReportId()与本地状态机测试,拆解举报原因、敏感信息拦截、重复案件回执和数据库唯一约束之间的边界。

上图为原创生成的举报去重流程插画,不是应用截图。同一个 targetReportId 再次提交时,当前单机实现返回已有案件,而不是继续生成新的待处理记录。
一、重复举报为什么不是"多存一条就行"
在失物招领场景中,用户可能因为网络迟缓、按钮重复点击、返回页面后再次进入,连续提交同一条内容举报。如果每次提交都生成案件,会出现三个直接问题:
- 消息中心同时显示多条相同进度;
- 治理人员可能对同一内容做出不同结论;
- 一条记录隐藏后,其他案件仍停留在"处理中";
- 案件统计把重复操作误算成多起问题;
- 用户不知道应该跟踪哪个案件编号。
因此举报提交不是简单的 INSERT。在创建新案件之前,必须先回答"这条目标记录是否已有可复用案件"。
二、本文对应的真实文件
本章结论来自以下项目文件,而不是抽象的后台系统设想:
| 层级 | 文件 | 责任 |
|---|---|---|
| ArkUI 页面 | entry/src/main/ets/pages/ModerationReportPage.ets |
选择原因、输入说明、展示提交状态 |
| Service | shared-business/src/main/ets/services/ModerationService.ets |
校验、查重、创建案件、返回业务结果 |
| Repository | shared-business/src/main/ets/repositories/ModerationRepository.ets |
RelationalStore 与本地回退数据读写 |
| Model | common-core/src/main/ets/models/AppContracts.ets |
原因、状态、动作和案件模型 |
| 测试 | scripts/local-state-machines.test.mjs |
验证重复提交返回同一个案件 ID |
页面只维护输入草稿态。是否允许举报、是否重复以及返回哪个案件,统一由 Service 和 Repository 决定。
三、先固定五类举报原因
ModerationService 当前提供五个枚举值:
| 枚举 | 页面文案 | 主要用途 |
|---|---|---|
PRIVACY |
泄露隐私 | 公开内容包含联系方式、精确地址等 |
FALSE_INFO |
虚假信息 | 内容明显失实或误导 |
ILLEGAL |
违法违规 | 涉及违法违规信息 |
INAPPROPRIATE_MEDIA |
图片或内容不适 | 媒体或描述不适合展示 |
OTHER |
其他问题 | 无法归入前四类的线索 |
固定枚举比自由填写原因更容易统计、筛选和制定处理规则。补充说明仍可保留细节,但它不能替代结构化原因。
四、页面禁用按钮不能代替 Service 校验
ModerationReportPage 会显示加载、提交中和错误状态,但真正的业务门禁在 submit() 中执行。即使未来从深链、消息页或其他入口发起请求,也必须经过相同校验。
ts
const result: OperationResult<ModerationCase> =
await moderationService.submit(
this.reportId,
this.selectedReason,
this.note
);
页面成功后跳转举报进度页;失败时展示 result.userMessage。它不会自行创建案件 ID,也不会直接操作数据库。
五、补充说明为什么限制为 200 字
当前 Service 对说明执行 trim(),并拒绝超过 200 字的内容。举报表单的目的不是开放第二个评论区,而是补充判断所需的最小事实。
过长文本会增加三个风险:用户粘贴整段聊天或个人信息、消息卡片难以摘要、未来服务端审核和日志存储成本上升。200 字是当前产品约束,不是 HarmonyOS 平台限制;正式上线后仍应根据真实治理流程调整。
六、敏感信息要在进入 Repository 前拦截
当前实现阻止手机号、身份证和"密码"等典型敏感内容:
ts
private containsSensitiveContent(value: string): boolean {
return value.includes('密码') ||
value.includes('身份证') ||
/1[3-9][0-9]{9}/.test(value);
}
这条规则只能覆盖典型误填,不是完整的数据防泄漏系统。用户仍可能通过空格、谐音或图片绕过。因此正式产品还需要字段最小化、图片治理、服务端规则和人工复核,不能把一条正则描述成"彻底消除隐私泄露"。
七、为什么禁止举报当前设备发布的记录
Service 在创建案件前读取目标 ItemReport,并检查 isUserCreated。当前单机演示把"当前设备创建"作为近似身份边界,自有记录应走编辑、撤回或删除,而不是进入举报流程。
这不是正式账号权限。设备标记不能证明真实作者身份,也无法阻止多设备场景下的伪造。联网版应由服务端根据登录主体和记录所有者判断,而不是信任客户端布尔值。
八、查重的核心是 targetReportId
当前 Repository 提供:
ts
async findByReportId(reportId: string):
Promise<ModerationCase | undefined> {
return this.findOne('target_report_id', reportId.trim(), this.context);
}
ModerationService.submit() 在插入前调用它。查到已有案件时,直接返回成功和原案件编号:
ts
if (existing) {
return new OperationResult<ModerationCase>(
true,
`该记录已有举报,案件编号:${existing.id}`,
existing
);
}
对用户而言,这是幂等式回执:重复操作不会制造新案件,同时仍能进入原进度页继续查看。
九、当前实现比大纲更严格:终态案件也会被复用
一个容易被忽略的真实细节是,findByReportId() 没有限制 PENDING 或 IN_REVIEW。只要该报告存在任何历史案件,包括 RESOLVED 或 REJECTED,再次举报都会返回旧案件。
这适合"同一条不可变内容只处理一次"的单机演示,却不一定适合正式产品。如果发布者修改了内容,或者出现新的违规事实,用户可能需要重新举报。
正式版可以把唯一范围改为"目标报告版本 + 活跃状态",或者引入 contentVersion:同一版本只允许一个活跃案件,新版本允许创建新案件,同时保留历史审计。
十、当前数据库没有 target_report_id 唯一索引
moderation_case 表只把 id 声明为主键,target_report_id 是普通非空列。这意味着"先查询、再插入"在单线程顺序执行中有效,但不是数据库级唯一保证。
两个并发请求可能同时查到不存在,然后分别插入不同的 CASE-*。本地时间戳 ID 也不是跨设备全局一致的业务约束。
正式服务端至少应选择一种方案:
- 对活跃案件建立条件唯一索引;
- 在事务内执行查重和插入;
- 使用
reportId + contentVersion作为幂等键; - 接口接收客户端操作 ID,并返回已创建结果;
- 对冲突错误转换为"返回已有案件",而不是通用失败。

上图对比两种保护层级:上方应用层先查后写仍可能被并发穿透;下方把事务和唯一约束放入权威数据库,冲突请求只返回已有案件,不再产生第二条记录。
十一、举报 ID 为什么不能由页面生成
当前 Service 使用 CASE-${Date.now()} 创建单机案件编号。页面只接收保存后的 saved.id 并跳转。
如果页面先生成 ID,再分别写列表、消息和详情,重试时很容易产生多个编号。正式联网版应由服务端或统一 ID 服务生成稳定标识,并把幂等结果作为响应返回。
十二、重复提交应该返回成功还是失败
这里选择返回 success=true,因为用户的目标"确保这条记录已进入举报流程"已经满足。提示语明确告诉用户已有案件,而不是伪装成本次新建成功。
如果返回通用失败,用户可能继续点击;如果新建第二条,又会污染治理队列。幂等成功与重复创建不同:前者复用同一个资源,后者制造额外副作用。
十三、消息中心如何消费复用结果
提交成功后页面进入 ModerationProgressPage,消息中心通过 moderationService.listCases() 读取案件。因为重复操作没有新增记录,消息页也只显示一条治理状态卡片。
卡片使用案件标题、短编号、当前状态和 outcomeSummary,并提供"查看进度"按钮。它展示业务状态,不提供自由输入,因此不会把重复举报演变成重复对话线程。
十四、本地测试如何证明去重
项目测试按以下顺序执行:
- 插入目标拾得记录;
- 以
PRIVACY提交首次举报; - 断言状态为
PENDING; - 再以
OTHER提交重复举报; - 断言第二次仍返回成功;
- 断言第二次返回的案件 ID 等于首次 ID;
- 继续验证越序隐藏被拒绝。
powershell
powershell -ExecutionPolicy Bypass -File .\scripts\test-local-state-machines.ps1
2026-08-23 本地实际运行通过。这个结果证明生产 Service 的本地回退路径遵守规则,不等于数据库并发唯一索引或多设备重复请求已经验证。
十五、异常回执要区分四类原因
用户看到的失败至少应区分:
| 场景 | 当前回执 |
|---|---|
| 原因非法 | 请选择举报原因 |
| 说明过长或含敏感信息 | 指向具体字段 |
| 记录不存在或属于当前设备 | 说明目标不可举报 |
| Repository 异常 | 举报提交失败,请稍后重试 |
把所有问题都写成"提交失败"会诱发重复操作,也不利于定位是输入、权限、目标状态还是存储故障。
十六、正式版还要处理报告被编辑和删除
举报提交后,目标报告可能被编辑、撤回或删除。案件不能只保存标题,否则治理人员无法知道当时举报的是哪个版本。
更完整的案件应保存内容版本、必要的脱敏快照、举报时状态和证据摘要。快照必须控制最小字段并设置保留期限,不能因为审计需要就永久复制全部个人信息。
十七、工程验收清单
上线前可按以下不变量验收:
- 同一目标版本最多一个活跃案件;
- 重复请求返回相同案件 ID;
- 请求重试不会增加消息卡片数量;
- 终态案件是否允许重开有明确产品规则;
- 自有记录由服务端身份判断;
- 敏感说明不会写入日志;
- 并发冲突由唯一约束或事务处理;
- 被编辑或删除后仍保留可解释的最小审计证据;
- 返回旧案件时明确告诉用户"已有举报"。
十八、工程复盘:查重规则必须包含时间维度
"按 reportId 查重"只是第一版答案。真正稳定的唯一语义通常是"目标内容的某个版本,在某个活跃窗口内,只允许一个治理案件"。缺少内容版本,历史驳回会阻塞对新内容的举报;缺少活跃状态,终态案件无法按规则重开;缺少数据库约束,并发仍能绕过 Service 查询。
因此实现前应先写唯一性表:唯一键由哪些字段组成,哪些状态算活跃,内容修改是否递增版本,案件关闭后多久允许新建,重复请求返回哪条记录。把这些问题留给页面按钮处理,会让多入口和多设备行为不一致。
十九、本文小结
举报去重的目标不是少存几行数据,而是让同一治理事实只有一个权威进度。当前"寻迹校园"已经在 Service 层校验原因、长度、敏感内容、自有记录,并在插入前按 targetReportId 复用已有案件;本地测试证明重复提交返回相同 ID。
但当前表结构没有目标唯一索引,查询也会复用终态案件。数据库并发、内容版本、真实身份、跨设备幂等和服务端审计仍未实现,不能从单机顺序测试外推。
系列导航:第 31 篇 / 共 50 篇。上一篇:《双方确认后才能结案》;下一篇:《受理、隐藏与驳回的强制状态顺序》。