【寻迹校园 HarmonyOS NEXT 实战 23】相反类型召回与稳定 Top 3:候选列表为什么先做门禁再排序

这是“寻迹校园 HarmonyOS NEXT 实战”系列第 23 篇。本文沿着 getMatchBundle() 的真实执行顺序,说明丢失记录为什么只召回拾得记录、候选状态如何门禁、Top 3 应该在何时截断,以及当前仅按分数排序时同分顺序还缺少什么工程保证。

相反类型召回与候选门禁原创封面图

上图为原创生成的流程插画,不是应用截图。左侧的丢失与拾得记录先经过类型、状态和自排除门禁,只有合法候选才能进入评分、排序与 Top 3。

一、召回和排序是两个不同阶段

很多列表实现直接把所有记录算分并排序,看起来只有一条链。业务上应明确拆成:

  1. 召回:决定哪些记录有资格参与;
  2. 排序:决定合法候选的相对位置;
  3. 截断:决定本次界面展示多少条;
  4. 解释:说明排序原因和冲突;
  5. 失效:权威记录变化后重新计算。

召回错误会把非法记录带入后续所有阶段;排序错误只影响合法候选的顺序。两者的风险和测试方法不同。

二、为什么必须召回相反发布类型

丢失记录的目标是寻找拾得记录,拾得记录的目标是寻找丢失记录。因此门禁是 report.reportType !== query.reportType

若不做这一步,两条“丢失黑色双肩包”可能因为分类、地点和日期都一致而获得高分,但它们只能说明两个人都在找包,不能形成认领对象。

相反类型门禁属于领域规则,应放在 ReportService。页面临时隐藏同类型条目并不安全,因为小艺适配器、测试或其他入口仍可能调用同一候选数据。

三、为什么要排除查询记录自身

report.id !== query.id 看似多余,因为相反类型已经会排除自身。保留它仍有价值:

  • 明确表达候选不能是查询对象本身;
  • 防止未来类型兼容、历史脏数据或迁移错误破坏假设;
  • 让测试和评审一眼看到自排除契约;
  • 后续若扩展“相似记录合并”,不会意外复用错误逻辑。

冗余门禁只要语义清晰且成本极低,可以作为防御式约束;但不能用它掩盖数据层主键或类型错误。

四、状态门禁决定记录是否仍具备业务资格

当前候选只接受 OPENCLAIMING

状态 是否召回 原因
OPEN 正在公开展示和匹配
CLAIMING 正在处理申请,仍可解释当前记录,但新动作受 Service 门禁
DRAFT 尚未发布
RESOLVED 已完成交接,不应产生新候选
WITHDRAWN 发布者已停止公开与匹配
HIDDEN 治理流程已隐藏

是否让 CLAIMING 继续出现在候选中属于产品策略。当前代码允许展示,但 ClaimService 会阻止对非 OPEN 记录再次提交认领,避免重复申请。

五、正确顺序是门禁、评分、排序、截断

当前实现顺序如下:

const candidates: MatchCandidate[] = reports
  .filter((report: ItemReport) => report.id !== query.id && report.reportType !== query.reportType &&
    (report.status === ReportStatus.OPEN || report.status === ReportStatus.CLAIMING))
  .map((report: ItemReport) => this.scoreCandidate(query, report))
  .sort((left: MatchCandidate, right: MatchCandidate) => right.score - left.score)
  .slice(0, 3);

如果先 slice(0, 3) 再排序,结果取决于 Repository 原始顺序,高分候选可能永远进不了前三;如果先评分再过滤,非法记录会消耗计算并可能进入中间数据;如果页面再做一次截断,不同调用方会看到不同集合。

业务 Service 应返回完成门禁与排序的候选契约,页面只负责展示。

六、为什么当前选择 Top 3

比赛演示中,Top 3 能在手机屏幕上保持较低的认知负担,也能让用户逐条查看相似点与冲突。小艺适配器同样最多取前三条并压缩到 240 字,保证显式复制内容短、可读、可脱敏复核。

Top 3 不是算法正确性的证明,也不是所有场景的永久上限。若真实校园数据中多个相似物品集中出现,三条可能造成漏看;若只有低质量候选,展示三条又可能制造噪声。

正式产品可以增加“查看更多”,但第一屏和 AI 摘要仍应保持有限集合。

七、当前“稳定 Top 3”还缺一个显式二级键

代码只比较 right.score - left.score。当两个候选同分时,比较器返回 0,顺序会继承输入列表的相对顺序;但业务代码没有把 Repository 排序规则和同分行为写成明确契约。

因此当前可以说“按分数降序且同分通常继承上游顺序”,不能声称“跨数据源、跨版本绝对稳定”。若 Repository 从内存回退改为数据库查询,或者查询未固定 ORDER BY,同分前三可能变化。

一个更明确的比较器可以使用分数、事件时间、创建时间和 ID:

function compareCandidate(left: MatchCandidate, right: MatchCandidate): number {
  if (left.score !== right.score) return right.score - left.score;
  if (left.report.createdAt !== right.report.createdAt) {
    return right.report.createdAt - left.report.createdAt;
  }
  return left.report.id.localeCompare(right.report.id);
}

这是建议方案,不是当前已合入代码。二级键的业务方向也需要确认:优先更新还是更早发布,不能只为“稳定”随便选择。

Top 3 排序与同分二级键原创图

上图展示门禁后的候选先按相似分排队,再用明确二级键打破同分,最后截取前三。稳定性来自排序契约,不来自“这次运行看起来没变”。

八、稳定顺序为什么影响用户信任

用户刷新页面时,如果数据未变化但第一名和第二名互换,会怀疑算法随机。对小艺链路而言,Top 3 变化还会改变摘要内容,使同一查询得到不同辅助解释。

稳定顺序也有利于测试和问题复现:测试报告能准确记录候选 ID,开发者可以比较规则变更前后的差异,而不是被输入顺序噪声干扰。

但稳定不等于正确。二级键只能消除同分随机性,不能弥补错误权重或遗漏候选。

九、Top 3 截断之前不要丢掉解释数据

评分阶段应同时生成 scorereasonsconflicts,排序阶段只比较分数及稳定键,截断后仍保留完整解释。

如果为了性能先只计算分数,进入前三后才补理由,两个实现可能因规则重复而漂移;如果理由来自另一个服务,页面又会出现分数和解释不一致。

一次评分生成一份不可分割的候选结果,更容易测试和审查。

十、候选列表如何随状态变化失效

候选不是单独持久化的权威表,而是当前报告列表的派生结果。报告发生这些变化时,下一次调用会自然重新计算:

  • 拾得记录被撤回或隐藏:不再通过状态门禁;
  • 安全交接完成:变成 RESOLVED,退出候选;
  • 公开分类、地点或日期被编辑:重新评分;
  • 新的对向记录发布:进入召回集合;
  • 认领被拒绝或取消:状态恢复 OPEN

当前页面通过数据变更回调和重新查询刷新。若未来引入候选缓存,必须为这些动作定义失效事件和版本号。

十一、空候选要区分三种原因

Top 3 为空可能来自:查询记录不存在、合法召回集合为空、Repository 或计算失败。

getMatchBundle() 对查询不存在返回失败;对合法候选为空返回成功的空集合;存储异常则应映射为错误。页面不能把三者统一写成“暂无数据”。

准确区分后,用户才能采取正确动作:返回重新选择报告、等待新信息、完善公开描述,或者重试读取。

十二、测试不只断言长度小于等于三

当前自动化已经验证:结果成功、查询 ID 正确、候选数量在 1~3、全部为相反类型、都有理由、分数非递增。

还需要增加:

  • 同类型高分记录仍被排除;
  • DRAFT/RESOLVED/WITHDRAWN/HIDDEN 全部被排除;
  • 查询自身不进入候选;
  • 四条以上合法候选时截断发生在排序之后;
  • 三条同分候选在固定二级键下顺序稳定;
  • Repository 返回顺序改变时业务结果仍稳定;
  • 候选状态变化后旧 Top 3 失效。

只有把 ID 顺序固定到断言里,才能证明“稳定 Top 3”。当前测试尚未覆盖显式同分二级键,因此本文把它列为改进项。

十三、大数据量时应把哪些门禁下推到数据库

当前实现先 list() 再在内存过滤,便于演示和测试。数据增长后,可以把相反类型、状态和自排除条件下推到 RelationalStore 查询,减少进入 ArkTS 层的记录数。

但数据库查询仍要有显式 ORDER BY,并保证与 Service 的评分契约一致。若评分规则不能完全下推,可以先用硬条件召回较小集合,再在 Service 中计算解释分。

任何优化都要通过相同测试向量验证结果一致,不能只比较响应时间。

十四、多人和多设备会带来新的竞态

当前项目是单机角色模拟。真实多人环境中,候选展示后可能立刻被他人认领、撤回或结案;用户点击时必须重新读取服务端权威状态。

Top 3 只是某个时间点的快照。提交认领时,ClaimService 或远端接口仍要检查目标是否 OPEN、是否已有待处理申请,并通过事务或版本号避免并发重复。

不能因为列表门禁正确,就推断后续写入没有竞态。

十五、可观测性应该记录规则版本而不是私密内容

排查排序问题时,可以记录查询 ID、候选 ID、规则版本、各维度命中标志和最终顺序,但不应打印手机号、证件号、私密核验答案或精确位置。

有了规则版本和候选 ID,开发者能重放测试;没有必要把用户私密描述复制进日志。可追踪性与最小化采集并不冲突。

十六、本文小结

稳定 Top 3 不是一句 sort().slice(0, 3) 就完成了。正确设计要先用相反类型、自排除和状态做召回门禁,再生成可解释分数,使用明确排序契约,最后截断,并在权威报告变化后重新计算。

“寻迹校园”当前已经完成门禁、降序排序和 Top 3,但同分候选尚未使用显式二级键。把这个缺口写清楚,比把一次演示顺序包装成永久稳定更符合工程实战。

系列导航:第 23 篇 / 共 50 篇。上一篇:《信息相似分不是所有权概率》;下一篇:《AI 不可用时的确定性降级》。

Logo

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

更多推荐